feat(validation): add Rule::Date and Rule::DateRange with chrono/jiff support#94
feat(validation): add Rule::Date and Rule::DateRange with chrono/jiff support#94
Conversation
… support Add date and date-range validation to walrs_validation: - DateFormat enum: Iso8601, UsDate, EuDate, Rfc2822, Custom - DateOptions and DateRangeOptions structs - Rule::Date(DateOptions) for date format validation - Rule::DateRange(DateRangeOptions) for date range validation - chrono and jiff as optional feature-flagged dependencies - Native type validation for NaiveDate/NaiveDateTime (chrono) and civil::Date/civil::DateTime (jiff) - Violation constructors: invalid_date, date_range_underflow, date_range_overflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add EmailOptions struct with configurable fields inspired by laminas-validator EmailAddress: allow_dns, allow_ip, allow_local, check_domain, min_local_part_length, max_local_part_length. Replace regex-based email validation with structured validate_email() that reuses validate_hostname() for domain part validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class date and date-range validation to walrs_validation, with optional backend support via chrono and/or jiff feature flags.
Changes:
- Introduces
Rule::Date(DateOptions)andRule::DateRange(DateRangeOptions)plus theDateFormatpresets/config. - Implements string validation dispatch with feature-gated backends (
chronopreferred when both are enabled) and native date/datetime validation for each backend. - Adds new
Violationconstructors and updates docs/README + feature flags/dependencies.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/validation/src/violation.rs | Adds date-specific violation constructors/messages. |
| crates/validation/src/rule_impls/value.rs | Enables Rule<Value> to validate date/date-range when the value is a string. |
| crates/validation/src/rule_impls/string.rs | Adds feature-gated dispatch for date/date-range string validation. |
| crates/validation/src/rule_impls/steppable.rs | Treats date rules as non-applicable for steppable types (pass-through). |
| crates/validation/src/rule_impls/scalar.rs | Treats date rules as non-applicable for scalar types (pass-through). |
| crates/validation/src/rule_impls/length.rs | Treats date rules as non-applicable for length-based validation. |
| crates/validation/src/rule_impls/mod.rs | Registers new date_chrono / date_jiff impl modules behind features. |
| crates/validation/src/rule_impls/date_chrono.rs | Implements chrono-based parsing + string and native validation + tests. |
| crates/validation/src/rule_impls/date_jiff.rs | Implements jiff-based parsing + string and native validation + tests. |
| crates/validation/src/rule_impls/attributes.rs | Ensures date rules don’t emit HTML attributes. |
| crates/validation/src/rule.rs | Adds new rule variants + constructors + Debug/PartialEq support. |
| crates/validation/src/options.rs | Adds DateFormat, DateOptions, DateRangeOptions (+ serde + tests). |
| crates/validation/src/lib.rs | Documents new date rules in crate-level docs. |
| crates/validation/README.md | Documents date validation usage + feature flags. |
| crates/validation/Cargo.toml | Adds chrono/jiff optional deps and feature flags. |
| Cargo.lock | Locks new dependency graph for chrono/jiff. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if let Ok(min_dt) = parse_bound_datetime(min_str) { | ||
| if dt < min_dt { | ||
| return Err(Violation::date_range_underflow(min_str)); | ||
| } | ||
| } | ||
| } | ||
| if let Some(max_str) = &opts.max { | ||
| if let Ok(max_dt) = parse_bound_datetime(max_str) { | ||
| if dt > max_dt { | ||
| return Err(Violation::date_range_overflow(max_str)); | ||
| } |
There was a problem hiding this comment.
DateRangeOptions.min/max parsing failures are silently ignored (the bound check is skipped when parse_bound_datetime fails). This can let out-of-range values pass if the rule is misconfigured. Consider returning a CustomError violation when a provided bound string can’t be parsed (and apply the same behavior for max and for the date-only path).
| if let Ok(min_dt) = parse_bound_datetime(min_str) { | |
| if dt < min_dt { | |
| return Err(Violation::date_range_underflow(min_str)); | |
| } | |
| } | |
| } | |
| if let Some(max_str) = &opts.max { | |
| if let Ok(max_dt) = parse_bound_datetime(max_str) { | |
| if dt > max_dt { | |
| return Err(Violation::date_range_overflow(max_str)); | |
| } | |
| match parse_bound_datetime(min_str) { | |
| Ok(min_dt) => { | |
| if dt < min_dt { | |
| return Err(Violation::date_range_underflow(min_str)); | |
| } | |
| } | |
| Err(_) => { | |
| // Misconfigured minimum bound: treat as validation error | |
| return Err(Violation::invalid_date()); | |
| } | |
| } | |
| } | |
| if let Some(max_str) = &opts.max { | |
| match parse_bound_datetime(max_str) { | |
| Ok(max_dt) => { | |
| if dt > max_dt { | |
| return Err(Violation::date_range_overflow(max_str)); | |
| } | |
| } | |
| Err(_) => { | |
| // Misconfigured maximum bound: treat as validation error | |
| return Err(Violation::invalid_date()); | |
| } |
crates/validation/src/options.rs
Outdated
| /// min/max range. The `min` and `max` bounds are stored as ISO 8601 strings | ||
| /// for serialization and parsed at validation time. | ||
| /// |
There was a problem hiding this comment.
The docs say min/max are “parsed at validation time”, but the current implementation silently skips bound checks when a bound string can’t be parsed (including the common case of allow_time = true with a date-only bound like 2020-01-01). Please either (a) document the exact required bound formats per allow_time, or (b) make parsing stricter/fallback so misconfigured bounds don’t silently disable range enforcement.
| /// min/max range. The `min` and `max` bounds are stored as ISO 8601 strings | |
| /// for serialization and parsed at validation time. | |
| /// | |
| /// min/max range. The `min` and `max` bounds are stored as strings for | |
| /// serialization and parsed at validation time using the configured | |
| /// [`DateFormat`] and [`DateOptions::allow_time`] settings. | |
| /// | |
| /// When `allow_time` is `false`, both the input and any `min`/`max` bounds | |
| /// must be date-only values in the chosen `format` (for the default | |
| /// `DateFormat::Iso8601`, this means `YYYY-MM-DD`, e.g. `"2020-01-01"`). | |
| /// | |
| /// When `allow_time` is `true`, both the input and any `min`/`max` bounds | |
| /// must include a time component in the chosen `format` (for the default | |
| /// `DateFormat::Iso8601`, this means a full date-time such as | |
| /// `"2020-01-01T00:00:00Z"` or `"2020-01-01T12:30:00+02:00"`). | |
| /// | |
| /// If a bound string cannot be parsed with the current `format` and | |
| /// `allow_time` combination, that side of the range is treated as if it were | |
| /// not set and the corresponding min/max check is skipped. | |
| /// |
There was a problem hiding this comment.
@copilot Please address this or create an issue ticket to updated the related code to properly handle the case when when date strings have different formats.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…nly bounds with allow_time=true, and vice versa) Co-authored-by: elycruz <603428+elycruz@users.noreply.github.com>
Fix silent bound-skip in Rule::DateRange when bound format mismatches allow_time
'allow_ip' adjust. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| NaiveDateTime::parse_from_str(value, &format!("{} %H:%M:%S", US_DATE_FMT)) | ||
| .map_err(|_| ()) |
There was a problem hiding this comment.
The format! macro creates a new heap allocation on every call. Consider using const format strings or lazy_static/once_cell to avoid repeated allocations. For example, you could define const US_DATETIME_FMT: &str = "%m/%d/%Y %H:%M:%S"; and const EU_DATETIME_FMT: &str = "%d/%m/%Y %H:%M:%S"; at the module level.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| let fmt = format!("{} %H:%M:%S", US_DATE_FMT); | ||
| DateTime::strptime(&fmt, value).map_err(|_| ()) |
There was a problem hiding this comment.
The format! macro creates a new heap allocation on every call. Consider using const format strings to avoid repeated allocations. For example, you could define const US_DATETIME_FMT: &str = "%m/%d/%Y %H:%M:%S"; and const EU_DATETIME_FMT: &str = "%d/%m/%Y %H:%M:%S"; at the module level.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
Co-authored-by: elycruz <603428+elycruz@users.noreply.github.com>
Fix `Rule::email()` API inconsistency: require `EmailOptions` parameter
feat(validation): promote Rule::Email to Email(EmailOptions)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…e_chrono.rs Co-authored-by: elycruz <603428+elycruz@users.noreply.github.com>
…e_jiff.rs and date_chrono.rs Co-authored-by: elycruz <603428+elycruz@users.noreply.github.com>
perf(validation): replace format! datetime strings with module-level consts
Replace `format!` datetime strings with module-level consts in `date_chrono.rs`
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 24 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// - When `allow_time` is `false`, bounds should be date-only values | ||
| /// (e.g., `"2020-01-01"`). | ||
| /// - When `allow_time` is `true`, bounds may be full date-time values | ||
| /// (e.g., `"2020-01-01T00:00:00"`) for precise time-based comparison. | ||
| /// | ||
| /// When there is a format mismatch, the bound is still applied using the | ||
| /// available date component: | ||
| /// | ||
| /// - A date-only bound with a datetime input (`allow_time = true`): the | ||
| /// date component of the input is compared against the bound (e.g., any | ||
| /// time on or after `"2020-01-01"` satisfies that min bound). | ||
| /// - A datetime bound with a date-only input (`allow_time = false`): the | ||
| /// date component of the bound is extracted for comparison. | ||
| /// | ||
| /// If a bound string cannot be parsed as either a date or datetime in the | ||
| /// configured format, that side of the range is treated as if it were not | ||
| /// set and the corresponding min/max check is skipped. | ||
| /// |
There was a problem hiding this comment.
Documentation inconsistency: The higher-level documentation (lines 362-383) states that bounds should be provided in the same DateFormat and allow_time setting as the input values. However, the implementation always parses bounds as ISO 8601 format (see parse_bound_date and parse_bound_datetime in date_chrono.rs:69-79 and date_jiff.rs:73-83). The field-level documentation on lines 413-419 correctly states bounds are "always specified in ISO 8601 format", but this conflicts with the "Bound format and cross-format handling" section above it.
The section describing cross-format handling (lines 362-383) should be removed or rewritten to clarify that bounds are always ISO 8601, and the fallback behavior is about whether the bound includes a time component (datetime vs date-only), not about different DateFormats.
| /// - When `allow_time` is `false`, bounds should be date-only values | |
| /// (e.g., `"2020-01-01"`). | |
| /// - When `allow_time` is `true`, bounds may be full date-time values | |
| /// (e.g., `"2020-01-01T00:00:00"`) for precise time-based comparison. | |
| /// | |
| /// When there is a format mismatch, the bound is still applied using the | |
| /// available date component: | |
| /// | |
| /// - A date-only bound with a datetime input (`allow_time = true`): the | |
| /// date component of the input is compared against the bound (e.g., any | |
| /// time on or after `"2020-01-01"` satisfies that min bound). | |
| /// - A datetime bound with a date-only input (`allow_time = false`): the | |
| /// date component of the bound is extracted for comparison. | |
| /// | |
| /// If a bound string cannot be parsed as either a date or datetime in the | |
| /// configured format, that side of the range is treated as if it were not | |
| /// set and the corresponding min/max check is skipped. | |
| /// | |
| /// Bounds (`min`/`max`) are always specified in ISO 8601 format, regardless of | |
| /// the chosen `DateFormat`: | |
| /// | |
| /// - Date-only: `"YYYY-MM-DD"` (e.g., `"2020-01-01"`). | |
| /// - Datetime: `"YYYY-MM-DDThh:mm:ss"` (optionally with a timezone offset, | |
| /// e.g., `"2020-01-01T00:00:00Z"`). | |
| /// | |
| /// The `allow_time` option controls how these bounds are interpreted: | |
| /// | |
| /// - When `allow_time` is `false`, only the date component of the input and | |
| /// bounds is used. Any time-of-day component present in a bound is ignored. | |
| /// Bounds should typically be date-only values (e.g., `"2020-01-01"`). | |
| /// - When `allow_time` is `true`, inputs are treated as datetimes. Bounds may | |
| /// be either date-only or full datetime values: | |
| /// - A date-only bound is applied using just the date component of the | |
| /// input (e.g., any time on or after `"2020-01-01"` satisfies that min | |
| /// bound). | |
| /// - A datetime bound is applied using the full datetime value of the input | |
| /// for precise time-based comparison. | |
| /// | |
| /// If a bound string cannot be parsed as a valid ISO 8601 date or datetime, | |
| /// that side of the range is treated as if it were not set and the | |
| /// corresponding min/max check is skipped. | |
| /// |
| match parse_bound_datetime(min_str) { | ||
| Ok(min_dt) => { | ||
| if dt < min_dt { | ||
| return Err(Violation::date_range_underflow(min_str)); | ||
| } | ||
| } | ||
| Err(_) => { | ||
| // Misconfigured `min` bound: treat as a validation error rather than | ||
| // silently skipping the lower-bound check. | ||
| return Err(Violation::invalid_date()); | ||
| } | ||
| } | ||
| } | ||
| if let Some(max_str) = &opts.max { | ||
| match parse_bound_datetime(max_str) { | ||
| Ok(max_dt) => { | ||
| if dt > max_dt { | ||
| return Err(Violation::date_range_overflow(max_str)); | ||
| } | ||
| } | ||
| Err(_) => { | ||
| // Misconfigured `max` bound: treat as a validation error rather than | ||
| // silently skipping the upper-bound check. | ||
| return Err(Violation::invalid_date()); | ||
| } | ||
| } else if let Ok(max_d) = parse_bound_date(max_str) { | ||
| if dt.date() > max_d { | ||
| return Err(Violation::date_range_overflow(max_str)); | ||
| } |
There was a problem hiding this comment.
Critical logic error: Lines 110-128 contain duplicated and unreachable code. The outer if let Ok(min_dt) = parse_bound_datetime(min_str) on line 112 is followed by an else if let Ok(min_d) = parse_bound_date(min_str) on line 116, but then line 117 contains a completely separate match parse_bound_datetime(min_str) block that makes lines 117-128 unreachable (they're nested inside the else if branch that only runs when datetime parsing fails, but then immediately try datetime parsing again).
The same pattern appears for the max bound on lines 130-147. This code should match the chrono implementation in date_chrono.rs lines 108-131, which correctly uses if let ... else if let without the nested match blocks.
| match parse_bound_datetime(min_str) { | |
| Ok(min_dt) => { | |
| if dt < min_dt { | |
| return Err(Violation::date_range_underflow(min_str)); | |
| } | |
| } | |
| Err(_) => { | |
| // Misconfigured `min` bound: treat as a validation error rather than | |
| // silently skipping the lower-bound check. | |
| return Err(Violation::invalid_date()); | |
| } | |
| } | |
| } | |
| if let Some(max_str) = &opts.max { | |
| match parse_bound_datetime(max_str) { | |
| Ok(max_dt) => { | |
| if dt > max_dt { | |
| return Err(Violation::date_range_overflow(max_str)); | |
| } | |
| } | |
| Err(_) => { | |
| // Misconfigured `max` bound: treat as a validation error rather than | |
| // silently skipping the upper-bound check. | |
| return Err(Violation::invalid_date()); | |
| } | |
| } else if let Ok(max_d) = parse_bound_date(max_str) { | |
| if dt.date() > max_d { | |
| return Err(Violation::date_range_overflow(max_str)); | |
| } | |
| if dt.date() < min_d { | |
| return Err(Violation::date_range_underflow(min_str)); | |
| } | |
| } else { | |
| // Misconfigured `min` bound: treat as a validation error rather than | |
| // silently skipping the lower-bound check. | |
| return Err(Violation::invalid_date()); | |
| } | |
| } | |
| if let Some(max_str) = &opts.max { | |
| // Try datetime bound first; fall back to date-only bound (compare date component) | |
| if let Ok(max_dt) = parse_bound_datetime(max_str) { | |
| if dt > max_dt { | |
| return Err(Violation::date_range_overflow(max_str)); | |
| } | |
| } else if let Ok(max_d) = parse_bound_date(max_str) { | |
| if dt.date() > max_d { | |
| return Err(Violation::date_range_overflow(max_str)); | |
| } | |
| } else { | |
| // Misconfigured `max` bound: treat as a validation error rather than | |
| // silently skipping the upper-bound check. | |
| return Err(Violation::invalid_date()); |
Summary
Add date and date-range validation to
walrs_validation.New Rule Variants
Rule::Date(DateOptions)— validates that a string is a parseable date in the given formatRule::DateRange(DateRangeOptions)— validates date format + checks min/max boundsDate Format Presets (
DateFormatenum)Iso8601—2026-02-23/2026-02-23T18:00:00UsDate—02/23/2026EuDate—23/02/2026Rfc2822—Mon, 23 Feb 2026 18:00:00Custom(String)— any strftime-style formatFeature Flags
chrono— enables string parsing and nativeRule<NaiveDate>/Rule<NaiveDateTime>validationjiff— enables string parsing and nativeRule<jiff::civil::Date>/Rule<jiff::civil::DateTime>validationdefault. When both are enabled,chronotakes precedence for string dispatch.New Violation Constructors
Violation::invalid_date()Violation::date_range_underflow(min)Violation::date_range_overflow(max)Tests