Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,12 @@ jobs:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: make ci
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cargo llvm-cov typically requires the Rust llvm-tools-preview component to be installed via rustup. The workflow installs cargo-llvm-cov but doesn't explicitly install llvm-tools-preview; if the install action doesn't handle it, the coverage step may fail. Consider adding a rustup component add llvm-tools-preview step (or documenting why it's unnecessary here).

Suggested change
uses: taiki-e/install-action@cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Install llvm-tools-preview
run: rustup component add llvm-tools-preview

Copilot uses AI. Check for mistakes.
- name: Generate coverage report
run: cargo llvm-cov --all-features --lcov --output-path lcov.info
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: tcrypt25519/fuzzy_date
Comment on lines +28 to +32
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Codecov upload step always references ${{ secrets.CODECOV_TOKEN }}. On pull requests from forks, secrets are not available, which can cause coverage upload failures depending on Codecov settings. Consider adding an if: guard to skip the upload when the token is unavailable (or omit token for public repos / configure the action to not fail when the token is empty).

Copilot uses AI. Check for mistakes.
30 changes: 30 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Contributing

Thanks for your interest in contributing to `fuzzy_date`.

## Style

- Format with `cargo fmt` before committing (`make fmt`).
- Fix all clippy warnings — the project runs clippy at `deny` level (`make clippy`).
- Add or update rustdoc comments for any public items you add or change.
- Keep changes focused; one logical change per commit/PR.

## Tests

- Every bug fix should include a regression test.
- Every new parsing rule or API surface should have unit tests.
- Run the full suite before opening a PR: `make ci` (format-check + clippy + tests + docs).

## Opening a PR

1. Fork the repository and create a feature branch from `main`.
2. Run `make ci` locally and confirm it passes.
3. Open a PR against `main` with a short description of what and why.

## Expectations

- Keep it small: PRs that are easier to review get merged faster.
- The crate has no runtime dependencies beyond `serde` and `thiserror`; please
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The list of runtime dependencies is incomplete. The project also depends on derive_more (as seen in Cargo.toml), which is used for implementing Display and other traits on the FuzzyDate enum.

Suggested change
- The crate has no runtime dependencies beyond `serde` and `thiserror`; please
- The crate has no runtime dependencies beyond serde, thiserror, and derive_more; please

avoid introducing new ones without discussion.
Comment on lines +27 to +28
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guideline says there are no runtime dependencies beyond serde and thiserror, but the crate also depends on derive_more (see Cargo.toml). Consider updating this to list all non-dev dependencies (or clarify that the intent is "no additional" dependencies beyond the current set).

Suggested change
- The crate has no runtime dependencies beyond `serde` and `thiserror`; please
avoid introducing new ones without discussion.
- Runtime dependencies are currently limited to `serde`, `thiserror`, and `derive_more`;
please avoid introducing new ones without discussion.

Copilot uses AI. Check for mistakes.
- Minimum supported Rust version is tracked in `Cargo.toml` (`rust-version`);
do not use features newer than that without bumping it.
13 changes: 10 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ doctest: ## Run doc tests
.PHONY: test-all
test-all: test doctest ## Run all tests including doctests

.PHONY: check
check: ## Run Cargo check
.PHONY: cargo-check
cargo-check: ## Run cargo check (type-check without linking)
cargo check --all-targets --all-features

.PHONY: check
check: fmt-check clippy test-all doc ## Run fmt-check, clippy, all tests, and docs in one command

Comment on lines +22 to +24
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make check now runs test-all, which depends on the test target that uses cargo nextest. On machines/CI environments without nextest installed, make check will fail even though make ci would pass. Consider switching check to use test-cargo/doctest, or make the test target fall back to cargo test when nextest isn't available.

Copilot uses AI. Check for mistakes.
.PHONY: fmt
fmt: ## Format code
cargo fmt --all
Expand All @@ -35,8 +38,12 @@ clippy: ## Run clippy
doc: ## Build documentation
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features

.PHONY: coverage
coverage: ## Generate HTML coverage report with cargo-llvm-cov (install: cargo install cargo-llvm-cov)
cargo llvm-cov --all-features --open
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the --open flag in the coverage target can cause issues in headless environments (such as CI runners or remote development containers) where a browser cannot be launched. It is generally better to generate the HTML report without automatically attempting to open it.

 	cargo llvm-cov --all-features --html


.PHONY: ci
ci: fmt-check clippy test-cargo ## Run all CI checks
ci: fmt-check clippy test-cargo doc ## Run all CI checks

.PHONY: clippy-fix
clippy-fix: ## Run clippy with automatic fixes
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

A Rust crate for dates with varying precision: year-only, year+month, or full date.

## Quick Start

1. Add the dependency: `fuzzy_date = "0.1"` in `[dependencies]`.
2. Parse a string: `"2026-02".parse::<FuzzyDate>()?`
3. Query bounds: `.lower_bound()` and `.upper_bound_inclusive()` return `(year, month, day)` tuples.
4. Check containment: `month.contains(&day_date)`.
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick Start step 4 uses day_date without introducing it, which makes the checklist harder to follow. Consider aligning it with the earlier example in the README (e.g., refer to day or explicitly say "a day-precision value").

Suggested change
4. Check containment: `month.contains(&day_date)`.
4. Check containment: `month.contains(&day)`.

Copilot uses AI. Check for mistakes.
5. Serde: values serialize/deserialize as ISO strings automatically.

## Background

Real-world date data often isn't complete. Vendors, forms, and manual records
Expand Down Expand Up @@ -77,5 +85,28 @@ storage, `to_columns()` / `from_columns()` map to three nullable columns

Ranges parse as `{start}/{end}` — e.g. `2020-03/2026-02-13`.

## Rejected formats

| Format | Example | Reason |
|------------------|------------------|-------------------------------------------------|
| `MM-DD-YYYY` | `02-13-2026` | Hyphens require year-first; ambiguous with ISO |
| `DD/MM/YYYY` | `13/02/2026` | Day-first slash not supported |
| `YYYYMMDD` | `20260213` | No separator; parsed as bare year or error |
| Mixed delimiters | `2026-02/13` | Any mix of `-` and `/` is rejected immediately |
| With time | `2026-02-13T10:00` | Time components are not accepted |

For fuller examples including ranges, database integration, construction, and
error handling, see [docs/examples.md](docs/examples.md).

## Security and Validation

Parsing is pure computation — no `unsafe` code, no I/O, no external calls.

Every component is validated before a `FuzzyDate` is constructed:

- Year must be 1–9999.
- Month must be 1–12.
- Day must be valid for the given month and year (leap years handled correctly).

A `FuzzyDate` value is always well-formed; the type system makes invalid dates
unrepresentable through the public API.
31 changes: 31 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,34 @@ helpers for lower/upper bounds.

- `cargo test` to run the included tests.
- Use `FuzzyDate::from_str("1991-08")?` or `"08/1991"`, etc.

## Error Behavior

`FromStr` returns `ParseError`, an enum with these variants:

| Variant | When raised |
|----------------------------------|----------------------------------------------------------|
| `EmptyInput` | Input is empty or all whitespace |
| `InvalidFormat(String)` | Unrecognized structure, mixed delimiters, non-digit tokens |
| `InvalidYear(u16)` | Year is 0 or exceeds 9999 |
Comment on lines +30 to +31
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "InvalidYear" / "InvalidFormat" descriptions don't fully match current behavior: year strings that overflow u16 (e.g. very long digit runs) are mapped to ParseError::InvalidFormat by parse_u16, not InvalidYear. Consider clarifying that InvalidFormat also covers numeric overflow (or adjust parsing to return InvalidYear for out-of-range numeric inputs when possible).

Suggested change
| `InvalidFormat(String)` | Unrecognized structure, mixed delimiters, non-digit tokens |
| `InvalidYear(u16)` | Year is 0 or exceeds 9999 |
| `InvalidFormat(String)` | Unrecognized structure, mixed delimiters, non-digit tokens, or numeric overflow during parsing |
| `InvalidYear(u16)` | Parsed year is 0 or exceeds 9999 (within `u16`; overflow is reported as `InvalidFormat`) |

Copilot uses AI. Check for mistakes.
| `InvalidMonth(u8)` | Month is 0 or exceeds 12 |
| `InvalidDay { month, day, year }`| Day does not exist in the given month/year |

All errors are diagnostic — each carries enough context to identify the offending
value. Parsing is pure computation: no I/O, no allocation beyond the error message.
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section says parsing performs "no allocation beyond the error message", but FromStr currently allocates on successful ISO parses by collecting trimmed.split('-') into a Vec<&str> in src/lib.rs (around the parts: Vec<&str> = ...collect() line). Either update the spec to reflect this, or change the implementation to avoid the allocation (e.g., parse via the split iterator without collecting).

Suggested change
value. Parsing is pure computation: no I/O, no allocation beyond the error message.
value. Parsing is pure computation: no I/O, and allocations are limited and primarily for diagnostics.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The claim that parsing involves "no allocation beyond the error message" is not entirely accurate for all formats. While the slash-delimited parser is optimized to avoid allocations, the ISO format parser (e.g., for YYYY-MM-DD) currently uses split().collect::<Vec<_>>(), which performs a heap allocation on successful parses.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Update the ISO parsing routine to work the same way as the slash format routine now works. Or as close as you can get to it. With the goal being to minimize allocations.

While the slash-delimited parser is optimized to avoid allocations, the ISO format parser (e.g., for YYYY-MM-DD) currently uses split().collect::<Vec<_>>(), which performs a heap allocation on successful parses.


## Module Architecture

The crate is organized into four private modules with a single public re-export layer:

| Module | Role |
|-----------|-----------------------------------------------------------------------------------|
| `lib` | Public API: `FuzzyDate`, `ParseError`, `FromStr`, `Ord`, serde impls |
| `types` | Validated newtypes: `Year`, `Month`, `Day` (backed by `NonZeroU16`/`NonZeroU8`) |
| `consts` | Named constants: month lengths, `MAX_YEAR`, `DAYS_IN_MONTH`, separator chars |
| `range` | `FuzzyDateRange` and `RangeError` |
| `prelude` | Internal re-exports shared across modules |

Dependency direction is strictly one-way: `lib` and `range` import from `types` and
`consts`; no leaf module imports from `lib` or `range`. This keeps compilation
incremental and prevents circular dependencies.
Comment on lines +50 to +52
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency-direction statement here doesn't reflect the actual module relationships: both types and range import items from the crate root (ParseError in types.rs, FuzzyDate/ParseError in range.rs). Consider revising this section to describe the real layering (e.g., types/consts are low-level, while range depends on FuzzyDate and thus the crate root).

Suggested change
Dependency direction is strictly one-way: `lib` and `range` import from `types` and
`consts`; no leaf module imports from `lib` or `range`. This keeps compilation
incremental and prevents circular dependencies.
In practice, `types` and `consts` form the low-level layer with no dependencies on other
crate modules. The crate root (`lib`) defines `FuzzyDate`, `ParseError`, and other traits
on top of them, and `range` builds `FuzzyDateRange` on `FuzzyDate`/`ParseError`, depending
on both the crate root and the low-level modules. This keeps the layering clear and helps
avoid circular dependencies in the implementation.

Copilot uses AI. Check for mistakes.
14 changes: 14 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,20 @@ impl FuzzyDate {
/// Uses a strict byte-by-byte walk: month and day fields are at most 2 ASCII
/// digits; any other byte in those positions is an immediate error. The year
/// field consumes all remaining bytes, which must all be ASCII digits.
///
/// # Why byte-by-byte instead of split('/')?
///
/// The two slash formats are structurally ambiguous at the second field: `MM/DD/YYYY`
/// has a 1-2 digit day followed by another `/`, while `MM/YYYY` has the year there
/// with no second `/`. A simple `split('/')` gives 2 or 3 parts, but that forces
/// a heap allocation and still requires per-field length checks.
///
/// The byte walker resolves the ambiguity without allocating by peeking ahead:
Comment on lines +323 to +326
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc comment claims that using split('/') forces a heap allocation. str::split itself is allocation-free; allocation only happens if you collect into a Vec (or otherwise allocate). Consider rewording this to avoid stating that split inherently allocates (e.g., clarify that collecting/length checks via collect() allocate).

Suggested change
/// with no second `/`. A simple `split('/')` gives 2 or 3 parts, but that forces
/// a heap allocation and still requires per-field length checks.
///
/// The byte walker resolves the ambiguity without allocating by peeking ahead:
/// with no second `/`. A simple approach like `split('/')` and collecting into a
/// `Vec` gives 2 or 3 owned parts, which requires a heap allocation for the
/// collection and still requires per-field length checks (even though `str::split`
/// itself is allocation-free).
///
/// The byte walker resolves the ambiguity while avoiding these allocations by
/// peeking ahead:

Copilot uses AI. Check for mistakes.
/// - If the second numeric run has 3+ digits, it must be the year → `MM/YYYY`.
/// - If after 1-2 digits the next byte is `/`, the run is a day → `MM/DD/YYYY`.
/// - If the input ends after 1-2 digits, the run is also the year → `MM/YYYY`.
///
/// Any other byte is an immediate error, keeping the state machine exhaustive.
fn parse_slash_date(s: &str) -> Result<Self, ParseError> {
let b = s.as_bytes();
let err = || ParseError::InvalidFormat(s.to_string());
Expand Down