Skip to content

feat(validation): add Rule::Date and Rule::DateRange with chrono/jiff support#94

Open
elycruz wants to merge 20 commits intomainfrom
feat/date-validation
Open

feat(validation): add Rule::Date and Rule::DateRange with chrono/jiff support#94
elycruz wants to merge 20 commits intomainfrom
feat/date-validation

Conversation

@elycruz
Copy link
Owner

@elycruz elycruz commented Feb 23, 2026

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 format
  • Rule::DateRange(DateRangeOptions) — validates date format + checks min/max bounds

Date Format Presets (DateFormat enum)

  • Iso86012026-02-23 / 2026-02-23T18:00:00
  • UsDate02/23/2026
  • EuDate23/02/2026
  • Rfc2822Mon, 23 Feb 2026 18:00:00
  • Custom(String) — any strftime-style format

Feature Flags

  • chrono — enables string parsing and native Rule<NaiveDate> / Rule<NaiveDateTime> validation
  • jiff — enables string parsing and native Rule<jiff::civil::Date> / Rule<jiff::civil::DateTime> validation
  • Neither is in default. When both are enabled, chrono takes precedence for string dispatch.

New Violation Constructors

  • Violation::invalid_date()
  • Violation::date_range_underflow(min)
  • Violation::date_range_overflow(max)

Tests

  • 313 unit tests + 37 doc-tests passing
  • Builds cleanly in all feature combinations (default, chrono-only, jiff-only, both)

… 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>
Base automatically changed from feat/hostname-validation to main February 23, 2026 19:15
@elycruz elycruz requested a review from Copilot February 23, 2026 19:17
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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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) and Rule::DateRange(DateRangeOptions) plus the DateFormat presets/config.
  • Implements string validation dispatch with feature-gated backends (chrono preferred when both are enabled) and native date/datetime validation for each backend.
  • Adds new Violation constructors 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.

Comment on lines +105 to +115
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));
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
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());
}

Copilot uses AI. Check for mistakes.
Comment on lines +285 to +287
/// min/max range. The `min` and `max` bounds are stored as ISO 8601 strings
/// for serialization and parsed at validation time.
///
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
/// 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.
///

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

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

@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.

elycruz and others added 2 commits February 23, 2026 14:29
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI commented Feb 23, 2026

@elycruz I've opened a new pull request, #96, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 2 commits February 23, 2026 19:41
…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
elycruz and others added 3 commits February 23, 2026 16:07
'allow_ip' adjust.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +49 to +50
NaiveDateTime::parse_from_str(value, &format!("{} %H:%M:%S", US_DATE_FMT))
.map_err(|_| ())
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +54 to +55
let fmt = format!("{} %H:%M:%S", US_DATE_FMT);
DateTime::strptime(&fmt, value).map_err(|_| ())
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

Copilot AI and others added 4 commits February 23, 2026 21:14
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)
Copy link
Contributor

Copilot AI commented Feb 23, 2026

@elycruz I've opened a new pull request, #98, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Contributor

Copilot AI commented Feb 23, 2026

@elycruz I've opened a new pull request, #99, to work on those changes. Once the pull request is ready, I'll request review from you.

elycruz and others added 3 commits February 23, 2026 16:27
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>
elycruz and others added 3 commits February 23, 2026 16:50
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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +367 to +384
/// - 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.
///
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
/// - 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.
///

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +145
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));
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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());

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants