diff --git a/Cargo.lock b/Cargo.lock index b7c575f..2c6a531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,15 @@ dependencies = [ "url", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -334,6 +343,19 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -406,6 +428,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -840,6 +868,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -997,6 +1049,47 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1293,6 +1386,21 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -2062,7 +2170,9 @@ dependencies = [ name = "walrs_validation" version = "0.1.0" dependencies = [ + "chrono", "indexmap", + "jiff", "regex", "serde", "serde_json", @@ -2149,12 +2259,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/crates/form/examples/localized_form.rs b/crates/form/examples/localized_form.rs index a75f857..f42968e 100644 --- a/crates/form/examples/localized_form.rs +++ b/crates/form/examples/localized_form.rs @@ -75,7 +75,7 @@ impl LocalizedFormValidator { _ => "Email is required".to_string(), }, None) .and( - Rule::::Email.with_message_provider(|ctx| match ctx.locale { + Rule::::Email(Default::default()).with_message_provider(|ctx| match ctx.locale { Some("es") => "El formato del correo electrónico no es válido".to_string(), Some("fr") => "Le format de l'adresse e-mail est invalide".to_string(), Some("de") => "Ungültiges E-Mail-Format".to_string(), diff --git a/crates/inputfilter/examples/field_basics.rs b/crates/inputfilter/examples/field_basics.rs index e73395f..fe01daa 100644 --- a/crates/inputfilter/examples/field_basics.rs +++ b/crates/inputfilter/examples/field_basics.rs @@ -60,7 +60,7 @@ fn main() { println!("\n3. Field with filters:"); let email_field = FieldBuilder::::default() .name("email") - .rule(Rule::Required.and(Rule::Email)) + .rule(Rule::Required.and(Rule::Email(Default::default()))) .filters(vec![Filter::Trim, Filter::Lowercase]) .build() .unwrap(); diff --git a/crates/inputfilter/examples/field_filter.rs b/crates/inputfilter/examples/field_filter.rs index 256fd32..1f8a35d 100644 --- a/crates/inputfilter/examples/field_filter.rs +++ b/crates/inputfilter/examples/field_filter.rs @@ -28,7 +28,7 @@ fn main() { // { "type": "email", "name": "email", "required": true, "minlength": 5, "maxlength": 128 } // Allows rules to be sent to front-end clients so that they're shared // instead of isolated. - .rule(All(vec![Required, MinLength(5), MaxLength(128), Email])) + .rule(All(vec![Required, MinLength(5), MaxLength(128), Email(Default::default())])) .build() .unwrap(), ) diff --git a/crates/inputfilter/examples/json_serialization.rs b/crates/inputfilter/examples/json_serialization.rs index 5802dc2..3ca20c0 100644 --- a/crates/inputfilter/examples/json_serialization.rs +++ b/crates/inputfilter/examples/json_serialization.rs @@ -32,7 +32,7 @@ fn main() { println!("\n2. Serialize a field with filters:"); let email_field = FieldBuilder::::default() .name("email") - .rule(Rule::Required.and(Rule::Email)) + .rule(Rule::Required.and(Rule::Email(Default::default()))) .filters(vec![Filter::Trim, Filter::Lowercase]) .build() .unwrap(); @@ -69,7 +69,7 @@ fn main() { // Example 5: Serialize Rule::Any println!("\n5. Serialize Rule::Any:"); - let any_rule = Rule::::Any(vec![Rule::Email, Rule::Pattern(r"^\d{10}$".to_string())]); + let any_rule = Rule::::Any(vec![Rule::Email(Default::default()), Rule::Pattern(r"^\d{10}$".to_string())]); let json = serde_json::to_string_pretty(&any_rule).unwrap(); println!("{}", json); diff --git a/crates/inputfilter/examples/localized_messages.rs b/crates/inputfilter/examples/localized_messages.rs index 71334ec..333e76c 100644 --- a/crates/inputfilter/examples/localized_messages.rs +++ b/crates/inputfilter/examples/localized_messages.rs @@ -194,7 +194,7 @@ fn main() { let email_rule = Rule::::Required .with_message_provider(|ctx| translate("email.required", ctx.locale), None) - .and(Rule::::Email.with_message_provider(|ctx| translate("email.invalid", ctx.locale), None)); + .and(Rule::::Email(Default::default()).with_message_provider(|ctx| translate("email.invalid", ctx.locale), None)); let invalid_email = "not-an-email".to_string(); diff --git a/crates/inputfilter/examples/rule_composition.rs b/crates/inputfilter/examples/rule_composition.rs index 81c9615..06a861b 100644 --- a/crates/inputfilter/examples/rule_composition.rs +++ b/crates/inputfilter/examples/rule_composition.rs @@ -72,7 +72,7 @@ fn main() { // Example 4: Using .or() combinator (Any must pass) println!("\n4. Using .or() combinator (Any must pass):"); - let contact_rule = Rule::::Email.or(Rule::Pattern(r"^\d{3}-\d{4}$".to_string())); + let contact_rule = Rule::::Email(Default::default()).or(Rule::Pattern(r"^\d{3}-\d{4}$".to_string())); println!( " 'user@example.com': {:?}", @@ -90,7 +90,7 @@ fn main() { // Example 5: Using Rule::Any directly println!("\n5. Using Rule::Any directly:"); let flexible_id = Rule::::Any(vec![ - Rule::Email, + Rule::Email(Default::default()), Rule::Pattern(r"^\d{5,10}$".to_string()), // Numeric ID Rule::Pattern(r"^[A-Z]{2}\d{6}$".to_string()), // Code format ]); @@ -155,7 +155,7 @@ fn main() { // Example 9: Pattern matching println!("\n9. Pattern matching:"); - let email_pattern = Rule::::Email; + let email_pattern = Rule::::Email(Default::default()); let url_pattern = Rule::::Url(Default::default()); let custom_pattern = Rule::::Pattern(r"^[a-z]+$".to_string()); diff --git a/crates/inputfilter/src/field.rs b/crates/inputfilter/src/field.rs index 3becca2..16731ac 100644 --- a/crates/inputfilter/src/field.rs +++ b/crates/inputfilter/src/field.rs @@ -37,7 +37,7 @@ use walrs_validation::{Rule, ValidateRef, Violations}; /// // Field with rule and filters /// let field = FieldBuilder::::default() /// .name("email") -/// .rule(Rule::Required.and(Rule::Email)) +/// .rule(Rule::Required.and(Rule::Email(Default::default()))) /// .filters(vec![Filter::Trim, Filter::Lowercase]) /// .build() /// .unwrap(); diff --git a/crates/inputfilter/src/lib.rs b/crates/inputfilter/src/lib.rs index 33e2d42..3020321 100644 --- a/crates/inputfilter/src/lib.rs +++ b/crates/inputfilter/src/lib.rs @@ -22,7 +22,7 @@ //! let email_field = FieldBuilder::::default() //! .name("email".to_string()) //! .filters(vec![FilterEnum::Trim, FilterEnum::Lowercase]) -//! .rule(Rule::Required.and(Rule::Email)) +//! .rule(Rule::Required.and(Rule::Email(Default::default()))) //! .build() //! .unwrap(); //! diff --git a/crates/validation/Cargo.toml b/crates/validation/Cargo.toml index c4e2c02..7fd65bd 100644 --- a/crates/validation/Cargo.toml +++ b/crates/validation/Cargo.toml @@ -11,9 +11,13 @@ include = ["src/**/*", "Cargo.toml"] default = ["serde_json_bridge"] serde_json_bridge = ["dep:serde_json"] indexmap = ["dep:indexmap"] +chrono = ["dep:chrono"] +jiff = ["dep:jiff"] [dependencies] +chrono = { version = "0.4", optional = true } indexmap = { version = "2", features = ["serde"], optional = true } +jiff = { version = "0.2", optional = true } regex = "1.3.1" serde = { version = "1.0.103", features = ["derive"] } serde_json = { version = "1.0.82", optional = true } diff --git a/crates/validation/README.md b/crates/validation/README.md index 5291b0c..e267f8d 100644 --- a/crates/validation/README.md +++ b/crates/validation/README.md @@ -13,9 +13,11 @@ The `Rule` enum provides built-in validation for common constraints: - `Rule::MinLength` / `Rule::MaxLength` - Length constraints - `Rule::Min` / `Rule::Max` - Range constraints - `Rule::Pattern` - Regex pattern matching -- `Rule::Email` - Email format validation +- `Rule::Email` - Configurable email validation (DNS/IP/local domains, local part length) - `Rule::Step` - Step/multiple validation - `Rule::Hostname` - Configurable hostname validation (DNS/IP/local/public IPv4) +- `Rule::Date` - Date format validation (ISO 8601, US, EU, RFC 2822, custom) +- `Rule::DateRange` - Date range validation with min/max bounds - `Rule::Custom` - Custom closure-based validation ## Rule Composition @@ -89,6 +91,54 @@ The `serde_json_bridge` feature (enabled by default) provides `From> for Value` for constructing `Value::Object` from an `IndexMap`. +### Date Validation (`chrono` / `jiff`) + +Date validation requires enabling one of the date crate features: + +```toml +# Using chrono (most popular, widest ecosystem) +walrs_validation = { path = "../validation", features = ["chrono"] } + +# Using jiff (modern API, best timezone handling) +walrs_validation = { path = "../validation", features = ["jiff"] } +``` + +**String-based validation** — validate date strings with `Rule::Date` and `Rule::DateRange`: + +```rust,ignore +use walrs_validation::{Rule, DateOptions, DateRangeOptions, DateFormat}; + +// Validate ISO 8601 date strings +let rule = Rule::::Date(DateOptions::default()); +assert!(rule.validate_str("2026-02-23").is_ok()); + +// Validate date range +let rule = Rule::::DateRange(DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: false, + min: Some("2020-01-01".into()), + max: Some("2030-12-31".into()), +}); +assert!(rule.validate_str("2025-06-15").is_ok()); +``` + +**Native type validation** — validate `chrono::NaiveDate` / `jiff::civil::Date` directly: + +```rust,ignore +// With chrono feature +use chrono::NaiveDate; +use walrs_validation::Rule; + +let min = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); +let max = NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(); +let rule = Rule::::Range { min, max }; + +let date = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap(); +assert!(rule.validate_date(&date).is_ok()); +``` + +When both features are enabled, `chrono` takes precedence for string parsing. + ## License MIT & Apache-2.0 diff --git a/crates/validation/src/lib.rs b/crates/validation/src/lib.rs index 3547d1c..ad4b148 100644 --- a/crates/validation/src/lib.rs +++ b/crates/validation/src/lib.rs @@ -12,11 +12,13 @@ //! - `Rule::MinLength` / `Rule::MaxLength` - Length constraints //! - `Rule::Min` / `Rule::Max` - Range constraints //! - `Rule::Pattern` - Regex pattern matching -//! - `Rule::Email` - Email format validation +//! - `Rule::Email` - Configurable email validation (DNS/IP/local domains, local part length) //! - `Rule::Url` - Configurable URL validation (scheme filtering) //! - `Rule::Uri` - Configurable URI validation (scheme, relative/absolute) //! - `Rule::Ip` - Configurable IP address validation (IPv4/IPv6/IPvFuture) //! - `Rule::Hostname` - Configurable hostname validation (DNS/IP/local/public IPv4) +//! - `Rule::Date` - Configurable date format validation (ISO 8601, US, EU, custom) +//! - `Rule::DateRange` - Date range validation with min/max bounds //! - `Rule::Step` - Step/multiple validation //! - `Rule::Custom` - Custom closure-based validation //! diff --git a/crates/validation/src/options.rs b/crates/validation/src/options.rs index d261231..3276c35 100644 --- a/crates/validation/src/options.rs +++ b/crates/validation/src/options.rs @@ -199,6 +199,237 @@ impl Default for HostnameOptions { } } +/// Options for email address validation (`Rule::Email`). +/// +/// Controls which forms of email addresses are accepted. The domain part +/// is validated using hostname rules; the local part is checked for length +/// and allowed characters. +/// +/// Inspired by laminas-validator's `EmailAddress` options, excluding +/// network-dependent options (`useMxCheck`, `useDeepMxCheck`). +/// +/// # Defaults +/// +/// - `allow_dns`: `true` +/// - `allow_ip`: `false` +/// - `allow_local`: `false` +/// - `check_domain`: `true` +/// - `min_local_part_length`: `1` +/// - `max_local_part_length`: `64` +/// +/// # Example +/// +/// ```rust +/// use walrs_validation::EmailOptions; +/// +/// // Accept emails with IP-literal domains, e.g. `user@[192.168.0.1]` +/// let opts = EmailOptions { +/// allow_ip: true, +/// ..Default::default() +/// }; +/// +/// // Accept emails with local hostnames, e.g. `user@localhost` +/// let opts = EmailOptions { +/// allow_local: true, +/// ..Default::default() +/// }; +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EmailOptions { + /// Allow DNS domain names in the email domain part, e.g. `user@example.com` + /// (default: true). + pub allow_dns: bool, + + /// Allow IP address literals in the email domain part, e.g. `user@[192.168.0.1]` + /// (default: false). + pub allow_ip: bool, + + /// Allow local/reserved hostnames in the email domain part, e.g. `user@localhost` + /// (default: false). + pub allow_local: bool, + + /// Whether to validate the domain part of the email address (default: true). + /// When false, only the local (user) part is checked. + pub check_domain: bool, + + /// Minimum length for the local part (default: 1). + pub min_local_part_length: usize, + + /// Maximum length for the local part (default: 64, per RFC 5321). + pub max_local_part_length: usize, +} + +impl Default for EmailOptions { + fn default() -> Self { + Self { + allow_dns: true, + allow_ip: false, + allow_local: false, + check_domain: true, + min_local_part_length: 1, + max_local_part_length: 64, + } + } +} + +/// Date format specification for date validation rules. +/// +/// Controls how date strings are parsed. Can be one of several common presets +/// or a custom strftime-style format string. +/// +/// # Defaults +/// +/// - `Iso8601` (e.g., `2026-02-23` or `2026-02-23T18:00:00`) +/// +/// # Example +/// +/// ```rust +/// use walrs_validation::DateFormat; +/// +/// let iso = DateFormat::Iso8601; +/// let us = DateFormat::UsDate; +/// let custom = DateFormat::Custom("%d %B %Y".into()); +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum DateFormat { + /// ISO 8601 date: `2026-02-23` or datetime: `2026-02-23T18:00:00` + Iso8601, + /// US-style date: `02/23/2026` + UsDate, + /// European-style date: `23/02/2026` + EuDate, + /// RFC 2822 date: `Mon, 23 Feb 2026 18:00:00` + Rfc2822, + /// Custom strftime-style format string (e.g., `%d %B %Y`) + Custom(String), +} + +impl Default for DateFormat { + fn default() -> Self { + Self::Iso8601 + } +} + +/// Options for date validation (`Rule::Date`). +/// +/// Controls the expected date format and whether a time component is accepted. +/// +/// # Defaults +/// +/// - `format`: `DateFormat::Iso8601` +/// - `allow_time`: `false` +/// +/// # Example +/// +/// ```rust +/// use walrs_validation::{DateOptions, DateFormat}; +/// +/// // Accept ISO 8601 date-only strings +/// let opts = DateOptions::default(); +/// +/// // Accept US-style dates with time +/// let opts = DateOptions { +/// format: DateFormat::UsDate, +/// allow_time: true, +/// }; +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DateOptions { + /// Expected date format (default: ISO 8601). + pub format: DateFormat, + + /// Whether to also accept a time component (default: false, date-only). + pub allow_time: bool, +} + +impl Default for DateOptions { + fn default() -> Self { + Self { + format: DateFormat::Iso8601, + allow_time: false, + } + } +} + +/// Options for date range validation (`Rule::DateRange`). +/// +/// Validates that a date string is parseable and falls within an optional +/// min/max range. The `min` and `max` bounds are stored as strings for +/// serialization and parsed at validation time using the configured +/// [`DateFormat`]. +/// +/// # Bound format and cross-format handling +/// +/// Bounds are ideally provided in the same [`DateFormat`] and `allow_time` +/// setting as the input values. For the default `DateFormat::Iso8601`: +/// +/// - 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. +/// +/// # Defaults +/// +/// - `format`: `DateFormat::Iso8601` +/// - `allow_time`: `false` +/// - `min`: `None` +/// - `max`: `None` +/// +/// # Example +/// +/// ```rust +/// use walrs_validation::{DateRangeOptions, DateFormat}; +/// +/// // Accept ISO dates between 2020-01-01 and 2030-12-31 +/// let opts = DateRangeOptions { +/// format: DateFormat::Iso8601, +/// allow_time: false, +/// min: Some("2020-01-01".into()), +/// max: Some("2030-12-31".into()), +/// }; +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DateRangeOptions { + /// Expected date format for validated input values (default: ISO 8601). + pub format: DateFormat, + + /// Whether to also accept a time component (default: false, date-only). + pub allow_time: bool, + + /// Minimum date/datetime (inclusive), always specified in ISO 8601 format, + /// regardless of the configured [`DateFormat`]. `None` means no lower bound. + pub min: Option, + + /// Maximum date/datetime (inclusive), always specified in ISO 8601 format, + /// regardless of the configured [`DateFormat`]. `None` means no upper bound. + pub max: Option, +} + +impl Default for DateRangeOptions { + fn default() -> Self { + Self { + format: DateFormat::Iso8601, + allow_time: false, + min: None, + max: None, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -285,4 +516,91 @@ mod tests { let deserialized: HostnameOptions = serde_json::from_str(&json).unwrap(); assert_eq!(opts, deserialized); } + + #[test] + fn test_email_options_default() { + let opts = EmailOptions::default(); + assert!(opts.allow_dns); + assert!(!opts.allow_ip); + assert!(!opts.allow_local); + assert!(opts.check_domain); + assert_eq!(opts.min_local_part_length, 1); + assert_eq!(opts.max_local_part_length, 64); + } + + #[test] + fn test_email_options_serialization() { + let opts = EmailOptions { + allow_dns: true, + allow_ip: true, + allow_local: false, + check_domain: true, + min_local_part_length: 2, + max_local_part_length: 32, + }; + let json = serde_json::to_string(&opts).unwrap(); + let deserialized: EmailOptions = serde_json::from_str(&json).unwrap(); + assert_eq!(opts, deserialized); + } + + #[test] + fn test_date_format_default() { + assert_eq!(DateFormat::default(), DateFormat::Iso8601); + } + + #[test] + fn test_date_format_serialization() { + let formats = vec![ + DateFormat::Iso8601, + DateFormat::UsDate, + DateFormat::EuDate, + DateFormat::Rfc2822, + DateFormat::Custom("%d %B %Y".into()), + ]; + for fmt in formats { + let json = serde_json::to_string(&fmt).unwrap(); + let deserialized: DateFormat = serde_json::from_str(&json).unwrap(); + assert_eq!(fmt, deserialized); + } + } + + #[test] + fn test_date_options_default() { + let opts = DateOptions::default(); + assert_eq!(opts.format, DateFormat::Iso8601); + assert!(!opts.allow_time); + } + + #[test] + fn test_date_options_serialization() { + let opts = DateOptions { + format: DateFormat::UsDate, + allow_time: true, + }; + let json = serde_json::to_string(&opts).unwrap(); + let deserialized: DateOptions = serde_json::from_str(&json).unwrap(); + assert_eq!(opts, deserialized); + } + + #[test] + fn test_date_range_options_default() { + let opts = DateRangeOptions::default(); + assert_eq!(opts.format, DateFormat::Iso8601); + assert!(!opts.allow_time); + assert!(opts.min.is_none()); + assert!(opts.max.is_none()); + } + + #[test] + fn test_date_range_options_serialization() { + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: false, + min: Some("2020-01-01".into()), + max: Some("2030-12-31".into()), + }; + let json = serde_json::to_string(&opts).unwrap(); + let deserialized: DateRangeOptions = serde_json::from_str(&json).unwrap(); + assert_eq!(opts, deserialized); + } } diff --git a/crates/validation/src/rule.rs b/crates/validation/src/rule.rs index a66bebf..26c785e 100644 --- a/crates/validation/src/rule.rs +++ b/crates/validation/src/rule.rs @@ -35,7 +35,7 @@ use std::fmt::{self, Debug}; use std::sync::Arc; use crate::{Message, MessageContext, SteppableValue, Violation}; -use crate::options::{HostnameOptions, IpOptions, UrlOptions, UriOptions}; +use crate::options::{DateOptions, DateRangeOptions, EmailOptions, HostnameOptions, IpOptions, UrlOptions, UriOptions}; use crate::traits::IsEmpty; // ============================================================================ @@ -195,8 +195,8 @@ pub enum Rule { /// Regex pattern match (stored as string for serialization) Pattern(String), - /// Email format validation - Email, + /// Email format validation with configurable options. + Email(EmailOptions), /// URL format validation with configurable options. Url(UrlOptions), @@ -210,6 +210,13 @@ pub enum Rule { /// Hostname validation with configurable options. Hostname(HostnameOptions), + // ---- Date Rules ---- + /// Date format validation (validates that a string is a parseable date). + Date(DateOptions), + + /// Date range validation (validates that a date falls within a range). + DateRange(DateRangeOptions), + // ---- Numeric Rules ---- /// Minimum value constraint Min(T), @@ -288,11 +295,13 @@ impl Debug for Rule { Self::MaxLength(n) => f.debug_tuple("MaxLength").field(n).finish(), Self::ExactLength(n) => f.debug_tuple("ExactLength").field(n).finish(), Self::Pattern(p) => f.debug_tuple("Pattern").field(p).finish(), - Self::Email => write!(f, "Email"), + Self::Email(opts) => f.debug_tuple("Email").field(opts).finish(), Self::Url(opts) => f.debug_tuple("Url").field(opts).finish(), Self::Uri(opts) => f.debug_tuple("Uri").field(opts).finish(), Self::Ip(opts) => f.debug_tuple("Ip").field(opts).finish(), Self::Hostname(opts) => f.debug_tuple("Hostname").field(opts).finish(), + Self::Date(opts) => f.debug_tuple("Date").field(opts).finish(), + Self::DateRange(opts) => f.debug_tuple("DateRange").field(opts).finish(), Self::Min(v) => f.debug_tuple("Min").field(v).finish(), Self::Max(v) => f.debug_tuple("Max").field(v).finish(), Self::Range { min, max } => f @@ -336,11 +345,13 @@ impl PartialEq for Rule { (Self::MaxLength(a), Self::MaxLength(b)) => a == b, (Self::ExactLength(a), Self::ExactLength(b)) => a == b, (Self::Pattern(a), Self::Pattern(b)) => a == b, - (Self::Email, Self::Email) => true, + (Self::Email(a), Self::Email(b)) => a == b, (Self::Url(a), Self::Url(b)) => a == b, (Self::Uri(a), Self::Uri(b)) => a == b, (Self::Ip(a), Self::Ip(b)) => a == b, (Self::Hostname(a), Self::Hostname(b)) => a == b, + (Self::Date(a), Self::Date(b)) => a == b, + (Self::DateRange(a), Self::DateRange(b)) => a == b, (Self::Min(a), Self::Min(b)) => a == b, (Self::Max(a), Self::Max(b)) => a == b, (Self::Range { min: a1, max: a2 }, Self::Range { min: b1, max: b2 }) => a1 == b1 && a2 == b2, @@ -430,7 +441,7 @@ impl Rule { /// ```rust /// use walrs_validation::rule::Rule; /// - /// let rule = Rule::::Email.or(Rule::Url(Default::default())); + /// let rule = Rule::::Email(Default::default()).or(Rule::Url(Default::default())); /// ``` pub fn or(self, other: Rule) -> Rule { match self { @@ -656,12 +667,11 @@ impl Rule { Rule::Pattern(pattern.into()) } - /// Creates an `Email` rule. - pub fn email() -> Rule { - Rule::Email + /// Creates an `Email` rule with the given options. + pub fn email(options: EmailOptions) -> Rule { + Rule::Email(options) } - /// Creates a `Url` rule. /// Creates a `Url` rule with the given options. pub fn url(options: UrlOptions) -> Rule { Rule::Url(options) @@ -682,6 +692,16 @@ impl Rule { Rule::Hostname(options) } + /// Creates a `Date` rule with the given options. + pub fn date(options: DateOptions) -> Rule { + Rule::Date(options) + } + + /// Creates a `DateRange` rule with the given options. + pub fn date_range(options: DateRangeOptions) -> Rule { + Rule::DateRange(options) + } + /// Creates a `Min` rule. pub fn min(value: T) -> Rule { Rule::Min(value) @@ -875,7 +895,7 @@ mod tests { #[test] fn test_rule_or_combinator() { - let rule1 = Rule::::Email; + let rule1 = Rule::::Email(Default::default()); let rule2 = Rule::::Url(Default::default()); let combined = rule1.or(rule2); diff --git a/crates/validation/src/rule_impls/attributes.rs b/crates/validation/src/rule_impls/attributes.rs index 99f015e..fb76bbd 100644 --- a/crates/validation/src/rule_impls/attributes.rs +++ b/crates/validation/src/rule_impls/attributes.rs @@ -73,7 +73,7 @@ impl ToAttributesList for Rule { "pattern".to_string(), serde_json::Value::from(p.clone()), )]), - Rule::Email => Some(vec![("type".to_string(), serde_json::Value::from("email"))]), + Rule::Email(_) => Some(vec![("type".to_string(), serde_json::Value::from("email"))]), Rule::Url(_) => Some(vec![("type".to_string(), serde_json::Value::from("url"))]), // Numeric Rules @@ -133,6 +133,8 @@ impl ToAttributesList for Rule { Rule::Uri(_) => None, Rule::Ip(_) => None, Rule::Hostname(_) => None, + Rule::Date(_) => None, + Rule::DateRange(_) => None, // WithMessage - delegate to inner rule Rule::WithMessage { rule, .. } => rule.to_attributes_list(), @@ -199,7 +201,7 @@ mod tests { #[test] fn test_to_attributes_list_email() { - let rule = Rule::::Email; + let rule = Rule::::Email(Default::default()); let attrs = rule.to_attributes_list().unwrap(); assert_eq!(attrs.len(), 1); assert_eq!(attrs[0].0, "type"); @@ -266,9 +268,8 @@ mod tests { #[test] fn test_to_attributes_list_any_composite() { - let rule = Rule::::Email.or(Rule::Url(Default::default())); + let rule = Rule::::Email(Default::default()).or(Rule::Url(Default::default())); let attrs = rule.to_attributes_list().unwrap(); - assert_eq!(attrs.len(), 2); assert!(attrs.iter().any(|(k, v)| k == "type" && v == "email")); assert!(attrs.iter().any(|(k, v)| k == "type" && v == "url")); } diff --git a/crates/validation/src/rule_impls/date_chrono.rs b/crates/validation/src/rule_impls/date_chrono.rs new file mode 100644 index 0000000..a53afb9 --- /dev/null +++ b/crates/validation/src/rule_impls/date_chrono.rs @@ -0,0 +1,624 @@ +//! Date validation helpers using the `chrono` crate. + +use chrono::NaiveDate; +use chrono::NaiveDateTime; + +use crate::options::{DateFormat, DateOptions, DateRangeOptions}; +use crate::rule::{Rule, RuleResult}; +use crate::traits::{Validate, ValidateRef}; +use crate::Violation; + +// ============================================================================ +// String Parsing Helpers +// ============================================================================ + +/// Format string for US-style date: `MM/DD/YYYY` +const US_DATE_FMT: &str = "%m/%d/%Y"; +/// Format string for EU-style date: `DD/MM/YYYY` +const EU_DATE_FMT: &str = "%d/%m/%Y"; +/// Format string for US-style datetime: `MM/DD/YYYY HH:MM:SS` +const US_DATETIME_FMT: &str = "%m/%d/%Y %H:%M:%S"; +/// Format string for EU-style datetime: `DD/MM/YYYY HH:MM:SS` +const EU_DATETIME_FMT: &str = "%d/%m/%Y %H:%M:%S"; + +/// Parses a date string using the given `DateFormat`, returning a `NaiveDate`. +pub(crate) fn parse_date_str(value: &str, format: &DateFormat) -> Result { + match format { + DateFormat::Iso8601 => NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| ()), + DateFormat::UsDate => NaiveDate::parse_from_str(value, US_DATE_FMT).map_err(|_| ()), + DateFormat::EuDate => NaiveDate::parse_from_str(value, EU_DATE_FMT).map_err(|_| ()), + DateFormat::Rfc2822 => { + // RFC 2822 typically includes time; try parsing as datetime and extract date + chrono::DateTime::parse_from_rfc2822(value) + .map(|dt| dt.date_naive()) + .map_err(|_| ()) + } + DateFormat::Custom(fmt) => NaiveDate::parse_from_str(value, fmt).map_err(|_| ()), + } +} + +/// Parses a datetime string using the given `DateFormat`, returning a `NaiveDateTime`. +pub(crate) fn parse_datetime_str( + value: &str, + format: &DateFormat, +) -> Result { + match format { + DateFormat::Iso8601 => { + // Try with 'T' separator first, then space + NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")) + .map_err(|_| ()) + } + DateFormat::UsDate => { + NaiveDateTime::parse_from_str(value, US_DATETIME_FMT) + .map_err(|_| ()) + } + DateFormat::EuDate => { + NaiveDateTime::parse_from_str(value, EU_DATETIME_FMT) + .map_err(|_| ()) + } + DateFormat::Rfc2822 => chrono::DateTime::parse_from_rfc2822(value) + .map(|dt| dt.naive_local()) + .map_err(|_| ()), + DateFormat::Custom(fmt) => { + NaiveDateTime::parse_from_str(value, fmt).map_err(|_| ()) + } + } +} + +/// Parses a bound string as a `NaiveDate` (always ISO 8601). +fn parse_bound_date(s: &str) -> Result { + NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| ()) +} + +/// Parses a bound string as a `NaiveDateTime` (always ISO 8601). +fn parse_bound_datetime(s: &str) -> Result { + NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")) + .map_err(|_| ()) +} + +// ============================================================================ +// String Validation Functions +// ============================================================================ + +/// Validates a string as a date per `DateOptions`. +pub(crate) fn validate_date_str(value: &str, opts: &DateOptions) -> RuleResult { + if opts.allow_time { + // Try datetime first, fall back to date-only + if parse_datetime_str(value, &opts.format).is_ok() { + return Ok(()); + } + if parse_date_str(value, &opts.format).is_ok() { + return Ok(()); + } + } else { + if parse_date_str(value, &opts.format).is_ok() { + return Ok(()); + } + } + Err(Violation::invalid_date()) +} + +/// Validates a string as a date within a range per `DateRangeOptions`. +pub(crate) fn validate_date_range_str(value: &str, opts: &DateRangeOptions) -> RuleResult { + if opts.allow_time { + // Try datetime first + if let Ok(dt) = parse_datetime_str(value, &opts.format) { + if let Some(min_str) = &opts.min { + // Try datetime bound first; fall back to date-only bound (compare date component) + if let Ok(min_dt) = parse_bound_datetime(min_str) { + if dt < min_dt { + return Err(Violation::date_range_underflow(min_str)); + } + } else if let Ok(min_d) = parse_bound_date(min_str) { + if dt.date() < min_d { + return Err(Violation::date_range_underflow(min_str)); + } + } + } + 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)); + } + } + } + return Ok(()); + } + // Fall back to date-only + if let Ok(d) = parse_date_str(value, &opts.format) { + return check_date_bounds(d, &opts.min, &opts.max); + } + } else { + if let Ok(d) = parse_date_str(value, &opts.format) { + return check_date_bounds(d, &opts.min, &opts.max); + } + } + Err(Violation::invalid_date()) +} + +fn check_date_bounds( + d: NaiveDate, + min: &Option, + max: &Option, +) -> RuleResult { + if let Some(min_str) = min { + // Try date-only bound first; fall back to datetime bound (extract date component) + let min_d = parse_bound_date(min_str) + .or_else(|_| parse_bound_datetime(min_str).map(|dt| dt.date())); + if let Ok(min_d) = min_d { + if d < min_d { + return Err(Violation::date_range_underflow(min_str)); + } + } + } + if let Some(max_str) = max { + // Try date-only bound first; fall back to datetime bound (extract date component) + let max_d = parse_bound_date(max_str) + .or_else(|_| parse_bound_datetime(max_str).map(|dt| dt.date())); + if let Ok(max_d) = max_d { + if d > max_d { + return Err(Violation::date_range_overflow(max_str)); + } + } + } + Ok(()) +} + +// ============================================================================ +// Native Type Validation: Rule +// ============================================================================ + +impl Rule { + /// Validates a `NaiveDate` value against this rule. + pub fn validate_date(&self, value: &NaiveDate) -> RuleResult { + self.validate_date_inner(value, None) + } + + fn validate_date_inner(&self, value: &NaiveDate, inherited_locale: Option<&str>) -> RuleResult { + match self { + Rule::Required => Ok(()), // A present NaiveDate is never empty + Rule::Min(min) => { + if value < min { + Err(Violation::range_underflow(min)) + } else { + Ok(()) + } + } + Rule::Max(max) => { + if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Range { min, max } => { + if value < min { + Err(Violation::range_underflow(min)) + } else if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Equals(expected) => { + if value == expected { + Ok(()) + } else { + Err(Violation::not_equal(expected)) + } + } + Rule::OneOf(allowed) => { + if allowed.contains(value) { + Ok(()) + } else { + Err(Violation::not_one_of()) + } + } + Rule::All(rules) => { + for rule in rules { + rule.validate_date_inner(value, inherited_locale)?; + } + Ok(()) + } + Rule::Any(rules) => { + if rules.is_empty() { + return Ok(()); + } + let mut last_err = None; + for rule in rules { + match rule.validate_date_inner(value, inherited_locale) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + Err(last_err.unwrap()) + } + Rule::Not(inner) => match inner.validate_date_inner(value, inherited_locale) { + Ok(()) => Err(Violation::negation_failed()), + Err(_) => Ok(()), + }, + Rule::Custom(f) => f(value), + Rule::Ref(name) => Err(Violation::unresolved_ref(name)), + Rule::WithMessage { rule, message, locale } => { + let effective_locale = locale.as_deref().or(inherited_locale); + match rule.validate_date_inner(value, effective_locale) { + Ok(()) => Ok(()), + Err(violation) => { + let custom_msg = message.resolve_or(value, violation.message(), effective_locale); + Err(Violation::new(violation.violation_type(), custom_msg)) + } + } + } + // Inapplicable rules pass through + _ => Ok(()), + } + } +} + +impl Validate for Rule { + fn validate(&self, value: NaiveDate) -> crate::ValidatorResult { + self.validate_date(&value) + } +} + +impl ValidateRef for Rule { + fn validate_ref(&self, value: &NaiveDate) -> crate::ValidatorResult { + self.validate_date(value) + } +} + +// ============================================================================ +// Native Type Validation: Rule +// ============================================================================ + +impl Rule { + /// Validates a `NaiveDateTime` value against this rule. + pub fn validate_datetime(&self, value: &NaiveDateTime) -> RuleResult { + self.validate_datetime_inner(value, None) + } + + fn validate_datetime_inner( + &self, + value: &NaiveDateTime, + inherited_locale: Option<&str>, + ) -> RuleResult { + match self { + Rule::Required => Ok(()), + Rule::Min(min) => { + if value < min { + Err(Violation::range_underflow(min)) + } else { + Ok(()) + } + } + Rule::Max(max) => { + if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Range { min, max } => { + if value < min { + Err(Violation::range_underflow(min)) + } else if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Equals(expected) => { + if value == expected { + Ok(()) + } else { + Err(Violation::not_equal(expected)) + } + } + Rule::OneOf(allowed) => { + if allowed.contains(value) { + Ok(()) + } else { + Err(Violation::not_one_of()) + } + } + Rule::All(rules) => { + for rule in rules { + rule.validate_datetime_inner(value, inherited_locale)?; + } + Ok(()) + } + Rule::Any(rules) => { + if rules.is_empty() { + return Ok(()); + } + let mut last_err = None; + for rule in rules { + match rule.validate_datetime_inner(value, inherited_locale) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + Err(last_err.unwrap()) + } + Rule::Not(inner) => match inner.validate_datetime_inner(value, inherited_locale) { + Ok(()) => Err(Violation::negation_failed()), + Err(_) => Ok(()), + }, + Rule::Custom(f) => f(value), + Rule::Ref(name) => Err(Violation::unresolved_ref(name)), + Rule::WithMessage { rule, message, locale } => { + let effective_locale = locale.as_deref().or(inherited_locale); + match rule.validate_datetime_inner(value, effective_locale) { + Ok(()) => Ok(()), + Err(violation) => { + let custom_msg = message.resolve_or(value, violation.message(), effective_locale); + Err(Violation::new(violation.violation_type(), custom_msg)) + } + } + } + _ => Ok(()), + } + } +} + +impl Validate for Rule { + fn validate(&self, value: NaiveDateTime) -> crate::ValidatorResult { + self.validate_datetime(&value) + } +} + +impl ValidateRef for Rule { + fn validate_ref(&self, value: &NaiveDateTime) -> crate::ValidatorResult { + self.validate_datetime(value) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::ViolationType; + + // --- String parsing tests --- + + #[test] + fn test_parse_iso_date() { + assert!(parse_date_str("2026-02-23", &DateFormat::Iso8601).is_ok()); + assert!(parse_date_str("not-a-date", &DateFormat::Iso8601).is_err()); + assert!(parse_date_str("02/23/2026", &DateFormat::Iso8601).is_err()); + } + + #[test] + fn test_parse_us_date() { + assert!(parse_date_str("02/23/2026", &DateFormat::UsDate).is_ok()); + assert!(parse_date_str("2026-02-23", &DateFormat::UsDate).is_err()); + } + + #[test] + fn test_parse_eu_date() { + assert!(parse_date_str("23/02/2026", &DateFormat::EuDate).is_ok()); + assert!(parse_date_str("02/23/2026", &DateFormat::EuDate).is_err()); + } + + #[test] + fn test_parse_custom_date() { + let fmt = DateFormat::Custom("%d %b %Y".into()); + assert!(parse_date_str("23 Feb 2026", &fmt).is_ok()); + assert!(parse_date_str("2026-02-23", &fmt).is_err()); + } + + #[test] + fn test_parse_iso_datetime() { + assert!(parse_datetime_str("2026-02-23T18:00:00", &DateFormat::Iso8601).is_ok()); + assert!(parse_datetime_str("2026-02-23 18:00:00", &DateFormat::Iso8601).is_ok()); + assert!(parse_datetime_str("2026-02-23", &DateFormat::Iso8601).is_err()); + } + + // --- String validation tests --- + + #[test] + fn test_validate_date_str_iso() { + let opts = DateOptions::default(); + assert!(validate_date_str("2026-02-23", &opts).is_ok()); + assert!(validate_date_str("not-valid", &opts).is_err()); + } + + #[test] + fn test_validate_date_str_with_time() { + let opts = DateOptions { + format: DateFormat::Iso8601, + allow_time: true, + }; + assert!(validate_date_str("2026-02-23T18:30:00", &opts).is_ok()); + assert!(validate_date_str("2026-02-23", &opts).is_ok()); + } + + #[test] + fn test_validate_date_str_rejects_time_when_not_allowed() { + let opts = DateOptions { + format: DateFormat::Iso8601, + allow_time: false, + }; + assert!(validate_date_str("2026-02-23T18:30:00", &opts).is_err()); + } + + #[test] + fn test_validate_date_range_str() { + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: false, + min: Some("2020-01-01".into()), + max: Some("2030-12-31".into()), + }; + assert!(validate_date_range_str("2025-06-15", &opts).is_ok()); + assert_eq!( + validate_date_range_str("2019-12-31", &opts).unwrap_err().violation_type(), + ViolationType::RangeUnderflow, + ); + assert_eq!( + validate_date_range_str("2031-01-01", &opts).unwrap_err().violation_type(), + ViolationType::RangeOverflow, + ); + } + + #[test] + fn test_validate_date_range_str_no_bounds() { + let opts = DateRangeOptions::default(); + assert!(validate_date_range_str("2099-12-31", &opts).is_ok()); + } + + #[test] + fn test_validate_date_range_datetime_with_date_only_bounds() { + // allow_time = true but bounds are date-only: should compare by date component + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: true, + min: Some("2020-01-01".into()), + max: Some("2030-12-31".into()), + }; + // datetime within range + assert!(validate_date_range_str("2025-06-15T12:00:00", &opts).is_ok()); + // datetime before min date + assert_eq!( + validate_date_range_str("2019-12-31T23:59:59", &opts).unwrap_err().violation_type(), + ViolationType::RangeUnderflow, + ); + // datetime after max date + assert_eq!( + validate_date_range_str("2031-01-01T00:00:00", &opts).unwrap_err().violation_type(), + ViolationType::RangeOverflow, + ); + } + + #[test] + fn test_validate_date_range_date_only_with_datetime_bounds() { + // allow_time = false but bounds include time: date component of bound is used + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: false, + min: Some("2020-01-01T00:00:00".into()), + max: Some("2030-12-31T23:59:59".into()), + }; + assert!(validate_date_range_str("2025-06-15", &opts).is_ok()); + assert_eq!( + validate_date_range_str("2019-12-31", &opts).unwrap_err().violation_type(), + ViolationType::RangeUnderflow, + ); + assert_eq!( + validate_date_range_str("2031-01-01", &opts).unwrap_err().violation_type(), + ViolationType::RangeOverflow, + ); + } + + // --- Native NaiveDate tests --- + + #[test] + fn test_rule_naive_date_min() { + let min = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + let rule = Rule::::Min(min); + + let ok_date = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap(); + let bad_date = NaiveDate::from_ymd_opt(2019, 12, 31).unwrap(); + + assert!(rule.validate_date(&ok_date).is_ok()); + assert!(rule.validate_date(&bad_date).is_err()); + } + + #[test] + fn test_rule_naive_date_range() { + let min = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + let max = NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(); + let rule = Rule::::Range { min, max }; + + let in_range = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap(); + let below = NaiveDate::from_ymd_opt(2019, 12, 31).unwrap(); + let above = NaiveDate::from_ymd_opt(2031, 1, 1).unwrap(); + + assert!(rule.validate_date(&in_range).is_ok()); + assert!(rule.validate_date(&below).is_err()); + assert!(rule.validate_date(&above).is_err()); + } + + #[test] + fn test_rule_naive_date_equals() { + let target = NaiveDate::from_ymd_opt(2026, 2, 23).unwrap(); + let rule = Rule::::Equals(target); + + assert!(rule.validate_date(&target).is_ok()); + let other = NaiveDate::from_ymd_opt(2026, 2, 24).unwrap(); + assert!(rule.validate_date(&other).is_err()); + } + + #[test] + fn test_rule_naive_date_one_of() { + let d1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let d2 = NaiveDate::from_ymd_opt(2026, 7, 4).unwrap(); + let rule = Rule::::OneOf(vec![d1, d2]); + + assert!(rule.validate_date(&d1).is_ok()); + let other = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(); + assert!(rule.validate_date(&other).is_err()); + } + + #[test] + fn test_rule_naive_date_composites() { + let min = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + let max = NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(); + let rule = Rule::::Min(min).and(Rule::Max(max)); + + let ok = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap(); + assert!(rule.validate_date(&ok).is_ok()); + + let bad = NaiveDate::from_ymd_opt(2031, 1, 1).unwrap(); + assert!(rule.validate_date(&bad).is_err()); + } + + // --- Native NaiveDateTime tests --- + + #[test] + fn test_rule_naive_datetime_range() { + let min = NaiveDate::from_ymd_opt(2020, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + let max = NaiveDate::from_ymd_opt(2030, 12, 31) + .unwrap() + .and_hms_opt(23, 59, 59) + .unwrap(); + let rule = Rule::::Range { min, max }; + + let in_range = NaiveDate::from_ymd_opt(2025, 6, 15) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap(); + assert!(rule.validate_datetime(&in_range).is_ok()); + + let below = NaiveDate::from_ymd_opt(2019, 12, 31) + .unwrap() + .and_hms_opt(23, 59, 59) + .unwrap(); + assert!(rule.validate_datetime(&below).is_err()); + } + + // --- Validate/ValidateRef trait tests --- + + #[test] + fn test_validate_trait_naive_date() { + let min = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + let rule = Rule::::Min(min); + + let ok_date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + assert!(Validate::validate(&rule, ok_date).is_ok()); + assert!(ValidateRef::validate_ref(&rule, &ok_date).is_ok()); + } +} diff --git a/crates/validation/src/rule_impls/date_jiff.rs b/crates/validation/src/rule_impls/date_jiff.rs new file mode 100644 index 0000000..c695888 --- /dev/null +++ b/crates/validation/src/rule_impls/date_jiff.rs @@ -0,0 +1,626 @@ +//! Date validation helpers using the `jiff` crate. + +// String dispatch helpers are only called when jiff is the active date crate +// (i.e., `jiff` enabled and `chrono` disabled). Suppress dead_code warnings +// when both features are enabled simultaneously. +#![cfg_attr(all(feature = "chrono", feature = "jiff"), allow(dead_code))] + +use jiff::civil::Date; +use jiff::civil::DateTime; + +use crate::options::{DateFormat, DateOptions, DateRangeOptions}; +use crate::rule::{Rule, RuleResult}; +use crate::traits::{Validate, ValidateRef}; +use crate::Violation; + +// ============================================================================ +// String Parsing Helpers +// ============================================================================ + +/// Format string for US-style date: `MM/DD/YYYY` +const US_DATE_FMT: &str = "%m/%d/%Y"; +/// Format string for EU-style date: `DD/MM/YYYY` +const EU_DATE_FMT: &str = "%d/%m/%Y"; +/// Format string for US-style datetime: `MM/DD/YYYY HH:MM:SS` +const US_DATETIME_FMT: &str = "%m/%d/%Y %H:%M:%S"; +/// Format string for EU-style datetime: `DD/MM/YYYY HH:MM:SS` +const EU_DATETIME_FMT: &str = "%d/%m/%Y %H:%M:%S"; + +/// Parses a date string using the given `DateFormat`, returning a `jiff::civil::Date`. +pub(crate) fn parse_date_str(value: &str, format: &DateFormat) -> Result { + match format { + DateFormat::Iso8601 => Date::strptime("%Y-%m-%d", value).map_err(|_| ()), + DateFormat::UsDate => Date::strptime(US_DATE_FMT, value).map_err(|_| ()), + DateFormat::EuDate => Date::strptime(EU_DATE_FMT, value).map_err(|_| ()), + DateFormat::Rfc2822 => { + // RFC 2822 includes time; parse as full timestamp and extract date + value + .parse::() + .map(|ts| ts.to_zoned(jiff::tz::TimeZone::UTC).date()) + .map_err(|_| ()) + } + DateFormat::Custom(fmt) => Date::strptime(fmt, value).map_err(|_| ()), + } +} + +/// Parses a datetime string using the given `DateFormat`, returning a `jiff::civil::DateTime`. +pub(crate) fn parse_datetime_str( + value: &str, + format: &DateFormat, +) -> Result { + match format { + DateFormat::Iso8601 => { + DateTime::strptime("%Y-%m-%dT%H:%M:%S", value) + .or_else(|_| DateTime::strptime("%Y-%m-%d %H:%M:%S", value)) + .map_err(|_| ()) + } + DateFormat::UsDate => { + DateTime::strptime(US_DATETIME_FMT, value).map_err(|_| ()) + } + DateFormat::EuDate => { + DateTime::strptime(EU_DATETIME_FMT, value).map_err(|_| ()) + } + DateFormat::Rfc2822 => { + value + .parse::() + .map(|ts| ts.to_zoned(jiff::tz::TimeZone::UTC).datetime()) + .map_err(|_| ()) + } + DateFormat::Custom(fmt) => DateTime::strptime(fmt, value).map_err(|_| ()), + } +} + +/// Parses a bound string as a `jiff::civil::Date` (always ISO 8601). +fn parse_bound_date(s: &str) -> Result { + Date::strptime("%Y-%m-%d", s).map_err(|_| ()) +} + +/// Parses a bound string as a `jiff::civil::DateTime` (always ISO 8601). +fn parse_bound_datetime(s: &str) -> Result { + DateTime::strptime("%Y-%m-%dT%H:%M:%S", s) + .or_else(|_| DateTime::strptime("%Y-%m-%d %H:%M:%S", s)) + .map_err(|_| ()) +} + +// ============================================================================ +// String Validation Functions +// ============================================================================ + +/// Validates a string as a date per `DateOptions`. +pub(crate) fn validate_date_str(value: &str, opts: &DateOptions) -> RuleResult { + if opts.allow_time { + if parse_datetime_str(value, &opts.format).is_ok() { + return Ok(()); + } + if parse_date_str(value, &opts.format).is_ok() { + return Ok(()); + } + } else { + if parse_date_str(value, &opts.format).is_ok() { + return Ok(()); + } + } + Err(Violation::invalid_date()) +} + +/// Validates a string as a date within a range per `DateRangeOptions`. +pub(crate) fn validate_date_range_str(value: &str, opts: &DateRangeOptions) -> RuleResult { + if opts.allow_time { + if let Ok(dt) = parse_datetime_str(value, &opts.format) { + if let Some(min_str) = &opts.min { + // Try datetime bound first; fall back to date-only bound (compare date component) + if let Ok(min_dt) = parse_bound_datetime(min_str) { + if dt < min_dt { + return Err(Violation::date_range_underflow(min_str)); + } + } else if let Ok(min_d) = parse_bound_date(min_str) { + 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)); + } + } + } + return Ok(()); + } + if let Ok(d) = parse_date_str(value, &opts.format) { + return check_date_bounds(d, &opts.min, &opts.max); + } + } else { + if let Ok(d) = parse_date_str(value, &opts.format) { + return check_date_bounds(d, &opts.min, &opts.max); + } + } + Err(Violation::invalid_date()) +} + +fn check_date_bounds( + d: Date, + min: &Option, + max: &Option, +) -> RuleResult { + if let Some(min_str) = min { + // Try date-only bound first; fall back to datetime bound (extract date component) + let min_d = parse_bound_date(min_str) + .or_else(|_| parse_bound_datetime(min_str).map(|dt| dt.date())); + if let Ok(min_d) = min_d { + if d < min_d { + return Err(Violation::date_range_underflow(min_str)); + } + } + } + if let Some(max_str) = max { + // Try date-only bound first; fall back to datetime bound (extract date component) + let max_d = parse_bound_date(max_str) + .or_else(|_| parse_bound_datetime(max_str).map(|dt| dt.date())); + if let Ok(max_d) = max_d { + if d > max_d { + return Err(Violation::date_range_overflow(max_str)); + } + } + } + Ok(()) +} + +// ============================================================================ +// Native Type Validation: Rule +// ============================================================================ + +impl Rule { + /// Validates a `jiff::civil::Date` value against this rule. + pub fn validate_date(&self, value: &Date) -> RuleResult { + self.validate_date_inner(value, None) + } + + fn validate_date_inner(&self, value: &Date, inherited_locale: Option<&str>) -> RuleResult { + match self { + Rule::Required => Ok(()), + Rule::Min(min) => { + if value < min { + Err(Violation::range_underflow(min)) + } else { + Ok(()) + } + } + Rule::Max(max) => { + if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Range { min, max } => { + if value < min { + Err(Violation::range_underflow(min)) + } else if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Equals(expected) => { + if value == expected { + Ok(()) + } else { + Err(Violation::not_equal(expected)) + } + } + Rule::OneOf(allowed) => { + if allowed.contains(value) { + Ok(()) + } else { + Err(Violation::not_one_of()) + } + } + Rule::All(rules) => { + for rule in rules { + rule.validate_date_inner(value, inherited_locale)?; + } + Ok(()) + } + Rule::Any(rules) => { + if rules.is_empty() { + return Ok(()); + } + let mut last_err = None; + for rule in rules { + match rule.validate_date_inner(value, inherited_locale) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + Err(last_err.unwrap()) + } + Rule::Not(inner) => match inner.validate_date_inner(value, inherited_locale) { + Ok(()) => Err(Violation::negation_failed()), + Err(_) => Ok(()), + }, + Rule::Custom(f) => f(value), + Rule::Ref(name) => Err(Violation::unresolved_ref(name)), + Rule::WithMessage { rule, message, locale } => { + let effective_locale = locale.as_deref().or(inherited_locale); + match rule.validate_date_inner(value, effective_locale) { + Ok(()) => Ok(()), + Err(violation) => { + let custom_msg = message.resolve_or(value, violation.message(), effective_locale); + Err(Violation::new(violation.violation_type(), custom_msg)) + } + } + } + _ => Ok(()), + } + } +} + +impl Validate for Rule { + fn validate(&self, value: Date) -> crate::ValidatorResult { + self.validate_date(&value) + } +} + +impl ValidateRef for Rule { + fn validate_ref(&self, value: &Date) -> crate::ValidatorResult { + self.validate_date(value) + } +} + +// ============================================================================ +// Native Type Validation: Rule +// ============================================================================ + +impl Rule { + /// Validates a `jiff::civil::DateTime` value against this rule. + pub fn validate_datetime(&self, value: &DateTime) -> RuleResult { + self.validate_datetime_inner(value, None) + } + + fn validate_datetime_inner( + &self, + value: &DateTime, + inherited_locale: Option<&str>, + ) -> RuleResult { + match self { + Rule::Required => Ok(()), + Rule::Min(min) => { + if value < min { + Err(Violation::range_underflow(min)) + } else { + Ok(()) + } + } + Rule::Max(max) => { + if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Range { min, max } => { + if value < min { + Err(Violation::range_underflow(min)) + } else if value > max { + Err(Violation::range_overflow(max)) + } else { + Ok(()) + } + } + Rule::Equals(expected) => { + if value == expected { + Ok(()) + } else { + Err(Violation::not_equal(expected)) + } + } + Rule::OneOf(allowed) => { + if allowed.contains(value) { + Ok(()) + } else { + Err(Violation::not_one_of()) + } + } + Rule::All(rules) => { + for rule in rules { + rule.validate_datetime_inner(value, inherited_locale)?; + } + Ok(()) + } + Rule::Any(rules) => { + if rules.is_empty() { + return Ok(()); + } + let mut last_err = None; + for rule in rules { + match rule.validate_datetime_inner(value, inherited_locale) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(e), + } + } + Err(last_err.unwrap()) + } + Rule::Not(inner) => match inner.validate_datetime_inner(value, inherited_locale) { + Ok(()) => Err(Violation::negation_failed()), + Err(_) => Ok(()), + }, + Rule::Custom(f) => f(value), + Rule::Ref(name) => Err(Violation::unresolved_ref(name)), + Rule::WithMessage { rule, message, locale } => { + let effective_locale = locale.as_deref().or(inherited_locale); + match rule.validate_datetime_inner(value, effective_locale) { + Ok(()) => Ok(()), + Err(violation) => { + let custom_msg = message.resolve_or(value, violation.message(), effective_locale); + Err(Violation::new(violation.violation_type(), custom_msg)) + } + } + } + _ => Ok(()), + } + } +} + +impl Validate for Rule { + fn validate(&self, value: DateTime) -> crate::ValidatorResult { + self.validate_datetime(&value) + } +} + +impl ValidateRef for Rule { + fn validate_ref(&self, value: &DateTime) -> crate::ValidatorResult { + self.validate_datetime(value) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::ViolationType; + + // --- String parsing tests --- + + #[test] + fn test_parse_iso_date() { + assert!(parse_date_str("2026-02-23", &DateFormat::Iso8601).is_ok()); + assert!(parse_date_str("not-a-date", &DateFormat::Iso8601).is_err()); + assert!(parse_date_str("02/23/2026", &DateFormat::Iso8601).is_err()); + } + + #[test] + fn test_parse_us_date() { + assert!(parse_date_str("02/23/2026", &DateFormat::UsDate).is_ok()); + assert!(parse_date_str("2026-02-23", &DateFormat::UsDate).is_err()); + } + + #[test] + fn test_parse_eu_date() { + assert!(parse_date_str("23/02/2026", &DateFormat::EuDate).is_ok()); + assert!(parse_date_str("02/23/2026", &DateFormat::EuDate).is_err()); + } + + #[test] + fn test_parse_custom_date() { + let fmt = DateFormat::Custom("%d %b %Y".into()); + assert!(parse_date_str("23 Feb 2026", &fmt).is_ok()); + assert!(parse_date_str("2026-02-23", &fmt).is_err()); + } + + #[test] + fn test_parse_iso_datetime() { + assert!(parse_datetime_str("2026-02-23T18:00:00", &DateFormat::Iso8601).is_ok()); + assert!(parse_datetime_str("2026-02-23 18:00:00", &DateFormat::Iso8601).is_ok()); + assert!(parse_datetime_str("2026-02-23", &DateFormat::Iso8601).is_err()); + } + + // --- String validation tests --- + + #[test] + fn test_validate_date_str_iso() { + let opts = DateOptions::default(); + assert!(validate_date_str("2026-02-23", &opts).is_ok()); + assert!(validate_date_str("not-valid", &opts).is_err()); + } + + #[test] + fn test_validate_date_str_with_time() { + let opts = DateOptions { + format: DateFormat::Iso8601, + allow_time: true, + }; + assert!(validate_date_str("2026-02-23T18:30:00", &opts).is_ok()); + assert!(validate_date_str("2026-02-23", &opts).is_ok()); + } + + #[test] + fn test_validate_date_str_rejects_time_when_not_allowed() { + let opts = DateOptions { + format: DateFormat::Iso8601, + allow_time: false, + }; + assert!(validate_date_str("2026-02-23T18:30:00", &opts).is_err()); + } + + #[test] + fn test_validate_date_range_str() { + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: false, + min: Some("2020-01-01".into()), + max: Some("2030-12-31".into()), + }; + assert!(validate_date_range_str("2025-06-15", &opts).is_ok()); + assert_eq!( + validate_date_range_str("2019-12-31", &opts).unwrap_err().violation_type(), + ViolationType::RangeUnderflow, + ); + assert_eq!( + validate_date_range_str("2031-01-01", &opts).unwrap_err().violation_type(), + ViolationType::RangeOverflow, + ); + } + + #[test] + fn test_validate_date_range_str_no_bounds() { + let opts = DateRangeOptions::default(); + assert!(validate_date_range_str("2099-12-31", &opts).is_ok()); + } + + #[test] + fn test_validate_date_range_datetime_with_date_only_bounds() { + // allow_time = true but bounds are date-only: should compare by date component + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: true, + min: Some("2020-01-01".into()), + max: Some("2030-12-31".into()), + }; + // datetime within range + assert!(validate_date_range_str("2025-06-15T12:00:00", &opts).is_ok()); + // datetime before min date + assert_eq!( + validate_date_range_str("2019-12-31T23:59:59", &opts).unwrap_err().violation_type(), + ViolationType::RangeUnderflow, + ); + // datetime after max date + assert_eq!( + validate_date_range_str("2031-01-01T00:00:00", &opts).unwrap_err().violation_type(), + ViolationType::RangeOverflow, + ); + } + + #[test] + fn test_validate_date_range_date_only_with_datetime_bounds() { + // allow_time = false but bounds include time: date component of bound is used + let opts = DateRangeOptions { + format: DateFormat::Iso8601, + allow_time: false, + min: Some("2020-01-01T00:00:00".into()), + max: Some("2030-12-31T23:59:59".into()), + }; + assert!(validate_date_range_str("2025-06-15", &opts).is_ok()); + assert_eq!( + validate_date_range_str("2019-12-31", &opts).unwrap_err().violation_type(), + ViolationType::RangeUnderflow, + ); + assert_eq!( + validate_date_range_str("2031-01-01", &opts).unwrap_err().violation_type(), + ViolationType::RangeOverflow, + ); + } + + // --- Native Date tests --- + + #[test] + fn test_rule_date_min() { + let min = Date::new(2020, 1, 1).unwrap(); + let rule = Rule::::Min(min); + + let ok_date = Date::new(2025, 6, 15).unwrap(); + let bad_date = Date::new(2019, 12, 31).unwrap(); + + assert!(rule.validate_date(&ok_date).is_ok()); + assert!(rule.validate_date(&bad_date).is_err()); + } + + #[test] + fn test_rule_date_range() { + let min = Date::new(2020, 1, 1).unwrap(); + let max = Date::new(2030, 12, 31).unwrap(); + let rule = Rule::::Range { min, max }; + + let in_range = Date::new(2025, 6, 15).unwrap(); + let below = Date::new(2019, 12, 31).unwrap(); + let above = Date::new(2031, 1, 1).unwrap(); + + assert!(rule.validate_date(&in_range).is_ok()); + assert!(rule.validate_date(&below).is_err()); + assert!(rule.validate_date(&above).is_err()); + } + + #[test] + fn test_rule_date_equals() { + let target = Date::new(2026, 2, 23).unwrap(); + let rule = Rule::::Equals(target); + + assert!(rule.validate_date(&target).is_ok()); + let other = Date::new(2026, 2, 24).unwrap(); + assert!(rule.validate_date(&other).is_err()); + } + + #[test] + fn test_rule_date_one_of() { + let d1 = Date::new(2026, 1, 1).unwrap(); + let d2 = Date::new(2026, 7, 4).unwrap(); + let rule = Rule::::OneOf(vec![d1, d2]); + + assert!(rule.validate_date(&d1).is_ok()); + let other = Date::new(2026, 3, 15).unwrap(); + assert!(rule.validate_date(&other).is_err()); + } + + #[test] + fn test_rule_date_composites() { + let min = Date::new(2020, 1, 1).unwrap(); + let max = Date::new(2030, 12, 31).unwrap(); + let rule = Rule::::Min(min).and(Rule::Max(max)); + + let ok = Date::new(2025, 6, 15).unwrap(); + assert!(rule.validate_date(&ok).is_ok()); + + let bad = Date::new(2031, 1, 1).unwrap(); + assert!(rule.validate_date(&bad).is_err()); + } + + // --- Native DateTime tests --- + + #[test] + fn test_rule_datetime_range() { + let min = DateTime::new(2020, 1, 1, 0, 0, 0, 0).unwrap(); + let max = DateTime::new(2030, 12, 31, 23, 59, 59, 0).unwrap(); + let rule = Rule::::Range { min, max }; + + let in_range = DateTime::new(2025, 6, 15, 12, 0, 0, 0).unwrap(); + assert!(rule.validate_datetime(&in_range).is_ok()); + + let below = DateTime::new(2019, 12, 31, 23, 59, 59, 0).unwrap(); + assert!(rule.validate_datetime(&below).is_err()); + } + + // --- Validate/ValidateRef trait tests --- + + #[test] + fn test_validate_trait_date() { + let min = Date::new(2020, 1, 1).unwrap(); + let rule = Rule::::Min(min); + + let ok_date = Date::new(2025, 1, 1).unwrap(); + assert!(Validate::validate(&rule, ok_date).is_ok()); + assert!(ValidateRef::validate_ref(&rule, &ok_date).is_ok()); + } +} diff --git a/crates/validation/src/rule_impls/length.rs b/crates/validation/src/rule_impls/length.rs index 0f1c464..a6efaf5 100644 --- a/crates/validation/src/rule_impls/length.rs +++ b/crates/validation/src/rule_impls/length.rs @@ -86,11 +86,13 @@ impl Rule { } // Non-length rules don't apply to collections - pass through Rule::Pattern(_) - | Rule::Email + | Rule::Email(_) | Rule::Url(_) | Rule::Uri(_) | Rule::Ip(_) | Rule::Hostname(_) + | Rule::Date(_) + | Rule::DateRange(_) | Rule::Min(_) | Rule::Max(_) | Rule::Range { .. } diff --git a/crates/validation/src/rule_impls/mod.rs b/crates/validation/src/rule_impls/mod.rs index c1ec4e2..43ab454 100644 --- a/crates/validation/src/rule_impls/mod.rs +++ b/crates/validation/src/rule_impls/mod.rs @@ -1,5 +1,9 @@ #[cfg(feature = "serde_json_bridge")] pub(crate) mod attributes; +#[cfg(feature = "chrono")] +pub(crate) mod date_chrono; +#[cfg(feature = "jiff")] +pub(crate) mod date_jiff; pub(crate) mod length; pub(crate) mod steppable; pub(crate) mod string; diff --git a/crates/validation/src/rule_impls/scalar.rs b/crates/validation/src/rule_impls/scalar.rs index 1004d01..2f39c51 100644 --- a/crates/validation/src/rule_impls/scalar.rs +++ b/crates/validation/src/rule_impls/scalar.rs @@ -127,11 +127,13 @@ impl Rule { | Rule::MaxLength(_) | Rule::ExactLength(_) | Rule::Pattern(_) - | Rule::Email + | Rule::Email(_) | Rule::Url(_) | Rule::Uri(_) | Rule::Ip(_) - | Rule::Hostname(_) => Ok(()), + | Rule::Hostname(_) + | Rule::Date(_) + | Rule::DateRange(_) => Ok(()), } } diff --git a/crates/validation/src/rule_impls/steppable.rs b/crates/validation/src/rule_impls/steppable.rs index 5cc6efe..d427cd7 100644 --- a/crates/validation/src/rule_impls/steppable.rs +++ b/crates/validation/src/rule_impls/steppable.rs @@ -115,11 +115,13 @@ impl Rule { | Rule::MaxLength(_) | Rule::ExactLength(_) | Rule::Pattern(_) - | Rule::Email + | Rule::Email(_) | Rule::Url(_) | Rule::Uri(_) | Rule::Ip(_) - | Rule::Hostname(_) => Ok(()), + | Rule::Hostname(_) + | Rule::Date(_) + | Rule::DateRange(_) => Ok(()), } } diff --git a/crates/validation/src/rule_impls/string.rs b/crates/validation/src/rule_impls/string.rs index 07870a6..4118419 100644 --- a/crates/validation/src/rule_impls/string.rs +++ b/crates/validation/src/rule_impls/string.rs @@ -2,7 +2,7 @@ use crate::rule::{Rule, RuleResult}; use crate::Violation; use crate::traits::ValidateRef; use crate::CompiledRule; -use crate::options::{HostnameOptions, UriOptions, UrlOptions, IpOptions}; +use crate::options::{DateOptions, DateRangeOptions, EmailOptions, HostnameOptions, UriOptions, UrlOptions, IpOptions}; // ============================================================================ // URI / IP Validation Helpers @@ -254,6 +254,136 @@ fn validate_hostname(value: &str, opts: &HostnameOptions) -> RuleResult { Ok(()) } +// ============================================================================ +// Email Validation Helper +// ============================================================================ + +/// Characters allowed in the local part of an email address (RFC 5321/5322 simplified). +fn is_valid_local_char(b: u8) -> bool { + b.is_ascii_alphanumeric() + || b"!#$%&'*+/=?^_`{|}~-.".contains(&b) +} + +/// Validates an email address string according to the given options. +fn validate_email(value: &str, opts: &EmailOptions) -> RuleResult { + // Split into local and domain parts + let at_pos = match value.rfind('@') { + Some(pos) => pos, + None => { + // No '@' — only valid if domain checking is disabled + if !opts.check_domain { + return validate_email_local_part(value, opts); + } + return Err(Violation::invalid_email()); + } + }; + + let local = &value[..at_pos]; + let domain = &value[at_pos + 1..]; + + // Validate local part + validate_email_local_part(local, opts)?; + + // Validate domain part (if enabled) + if opts.check_domain { + if domain.is_empty() { + return Err(Violation::invalid_email()); + } + + // Handle IP-literal domains: `[192.168.0.1]` or `[IPv6:::1]` + if domain.starts_with('[') && domain.ends_with(']') { + if !opts.allow_ip { + return Err(Violation::invalid_email()); + } + let inner = &domain[1..domain.len() - 1]; + // Strip optional "IPv6:" prefix for IPv6 literals + let ip_str = inner.strip_prefix("IPv6:").unwrap_or(inner); + if ip_str.parse::().is_err() + && ip_str.parse::().is_err() + { + return Err(Violation::invalid_email()); + } + return Ok(()); + } + + // Validate as a hostname + let hostname_opts = HostnameOptions { + allow_dns: opts.allow_dns, + allow_ip: false, + allow_local: opts.allow_local, + require_public_ipv4: false, + }; + validate_hostname(domain, &hostname_opts) + .map_err(|_| Violation::invalid_email())?; + } + + Ok(()) +} + +/// Validates the local part of an email address. +fn validate_email_local_part(local: &str, opts: &EmailOptions) -> RuleResult { + let len = local.len(); + if len < opts.min_local_part_length || len > opts.max_local_part_length { + return Err(Violation::invalid_email()); + } + + // Must not start or end with a dot + if local.starts_with('.') || local.ends_with('.') { + return Err(Violation::invalid_email()); + } + + // No consecutive dots + if local.contains("..") { + return Err(Violation::invalid_email()); + } + + // Check allowed characters + if !local.bytes().all(is_valid_local_char) { + return Err(Violation::invalid_email()); + } + + Ok(()) +} + +/// Dispatches date string validation to the active date crate. +/// When both `chrono` and `jiff` are enabled, `chrono` takes precedence. +#[cfg(feature = "chrono")] +fn validate_date_str_dispatch(value: &str, opts: &DateOptions) -> RuleResult { + crate::rule_impls::date_chrono::validate_date_str(value, opts) +} + +#[cfg(all(feature = "jiff", not(feature = "chrono")))] +fn validate_date_str_dispatch(value: &str, opts: &DateOptions) -> RuleResult { + crate::rule_impls::date_jiff::validate_date_str(value, opts) +} + +#[cfg(not(any(feature = "chrono", feature = "jiff")))] +fn validate_date_str_dispatch(_value: &str, _opts: &DateOptions) -> RuleResult { + Err(Violation::new( + crate::ViolationType::CustomError, + "Date validation requires the `chrono` or `jiff` feature.", + )) +} + +/// Dispatches date range string validation to the active date crate. +#[cfg(feature = "chrono")] +fn validate_date_range_str_dispatch(value: &str, opts: &DateRangeOptions) -> RuleResult { + crate::rule_impls::date_chrono::validate_date_range_str(value, opts) +} + +#[cfg(all(feature = "jiff", not(feature = "chrono")))] +fn validate_date_range_str_dispatch(value: &str, opts: &DateRangeOptions) -> RuleResult { + crate::rule_impls::date_jiff::validate_date_range_str(value, opts) +} + +#[cfg(not(any(feature = "chrono", feature = "jiff")))] +fn validate_date_range_str_dispatch(_value: &str, _opts: &DateRangeOptions) -> RuleResult { + Err(Violation::new( + crate::ViolationType::CustomError, + "Date range validation requires the `chrono` or `jiff` feature.", + )) +} + /// Cached validators for a compiled rule. /// /// This struct holds compiled regex patterns for string validation rules. @@ -262,8 +392,6 @@ fn validate_hostname(value: &str, opts: &HostnameOptions) -> RuleResult { pub(crate) struct CachedStringValidators { /// Cached regex for Pattern rules pub(crate) pattern_regex: Option, - /// Cached email regex - pub(crate) email_regex: Option, } impl CachedStringValidators { @@ -325,20 +453,13 @@ impl Rule { } Err(_) => Err(Violation::pattern_mismatch(pattern)), }, - Rule::Email => { - // Simple email validation using regex - let email_re = - regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); - if email_re.is_match(value) { - Ok(()) - } else { - Err(Violation::invalid_email()) - } - } + Rule::Email(opts) => validate_email(value, opts), Rule::Url(opts) => validate_url(value, opts), Rule::Uri(opts) => validate_uri(value, opts), Rule::Ip(opts) => validate_ip(value, opts), Rule::Hostname(opts) => validate_hostname(value, opts), + Rule::Date(opts) => validate_date_str_dispatch(value, opts), + Rule::DateRange(opts) => validate_date_range_str_dispatch(value, opts), Rule::Equals(expected) => { if value == expected { Ok(()) @@ -529,10 +650,6 @@ impl CompiledRule { cache.pattern_regex = regex::Regex::new(pattern).ok(); } - // Pre-compile email regex - cache.email_regex = - regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").ok(); - cache }) } @@ -592,22 +709,13 @@ impl CompiledRule { Err(Violation::pattern_mismatch(pattern)) } } - Rule::Email => { - let matches = cache - .email_regex - .as_ref() - .map(|re| re.is_match(value)) - .unwrap_or(false); - if matches { - Ok(()) - } else { - Err(Violation::invalid_email()) - } - } + Rule::Email(opts) => validate_email(value, opts), Rule::Url(opts) => validate_url(value, opts), Rule::Uri(opts) => validate_uri(value, opts), Rule::Ip(opts) => validate_ip(value, opts), Rule::Hostname(opts) => validate_hostname(value, opts), + Rule::Date(opts) => validate_date_str_dispatch(value, opts), + Rule::DateRange(opts) => validate_date_range_str_dispatch(value, opts), Rule::Equals(expected) => { if value == expected { Ok(()) @@ -738,11 +846,80 @@ mod tests { #[test] fn test_validate_str_email() { - let rule = Rule::::Email; + let rule = Rule::::Email(Default::default()); assert!(rule.validate_str("user@example.com").is_ok()); assert!(rule.validate_str("user@sub.example.com").is_ok()); assert!(rule.validate_str("invalid").is_err()); assert!(rule.validate_str("@example.com").is_err()); + assert!(rule.validate_str("user@").is_err()); + assert!(rule.validate_str("").is_err()); + assert!(rule.validate_str(".user@example.com").is_err()); + assert!(rule.validate_str("user.@example.com").is_err()); + assert!(rule.validate_str("u..ser@example.com").is_err()); + } + + #[test] + fn test_validate_str_email_allow_ip() { + let rule = Rule::::Email(crate::EmailOptions { + allow_ip: true, + ..Default::default() + }); + assert!(rule.validate_str("user@[192.168.0.1]").is_ok()); + assert!(rule.validate_str("user@[IPv6:::1]").is_ok()); + assert!(rule.validate_str("user@example.com").is_ok()); + // IP without brackets must still be rejected (RFC 5321 requires brackets) + assert!(rule.validate_str("user@192.168.0.1").is_err()); + assert!(rule.validate_str("user@[not-an-ip]").is_err()); + } + + #[test] + fn test_validate_str_email_allow_local() { + let rule = Rule::::Email(crate::EmailOptions { + allow_local: true, + ..Default::default() + }); + assert!(rule.validate_str("user@localhost").is_ok()); + assert!(rule.validate_str("user@example.com").is_ok()); + + // Without allow_local, localhost is rejected + let strict = Rule::::Email(Default::default()); + assert!(strict.validate_str("user@localhost").is_err()); + } + + #[test] + fn test_validate_str_email_no_check_domain() { + let rule = Rule::::Email(crate::EmailOptions { + check_domain: false, + ..Default::default() + }); + assert!(rule.validate_str("user").is_ok()); + assert!(rule.validate_str("user@anything").is_ok()); + assert!(rule.validate_str("").is_err()); // still enforces min_local_part_length + } + + #[test] + fn test_validate_str_email_local_part_length() { + let rule = Rule::::Email(crate::EmailOptions { + min_local_part_length: 3, + max_local_part_length: 10, + ..Default::default() + }); + assert!(rule.validate_str("abc@example.com").is_ok()); + assert!(rule.validate_str("ab@example.com").is_err()); // too short + assert!(rule.validate_str("abcdefghijk@example.com").is_err()); // too long (11 chars) + assert!(rule.validate_str("abcdefghij@example.com").is_ok()); // exactly 10 + } + + #[test] + fn test_validate_str_email_ip_rejected_by_default() { + let rule = Rule::::Email(Default::default()); + assert!(rule.validate_str("user@[192.168.0.1]").is_err()); + } + + #[test] + fn test_validate_str_email_local_rejected_by_default() { + let rule = Rule::::Email(Default::default()); + assert!(rule.validate_str("user@localhost").is_err()); } #[test] @@ -837,7 +1014,7 @@ mod tests { #[test] fn test_validate_str_any() { - let rule = Rule::::Email.or(Rule::Url(Default::default())); + let rule = Rule::::Email(Default::default()).or(Rule::Url(Default::default())); assert!(rule.validate_str("user@example.com").is_ok()); assert!(rule.validate_str("http://example.com").is_ok()); assert!(rule.validate_str("neither").is_err()); @@ -897,7 +1074,7 @@ mod tests { let rule = Rule::::Pattern(r"^\d+$".to_string()); assert!(rule.validate_str_option(None).is_ok()); - let rule = Rule::::Email; + let rule = Rule::::Email(Default::default()); assert!(rule.validate_str_option(None).is_ok()); } @@ -977,7 +1154,7 @@ mod tests { #[test] fn test_compiled_rule_email() { - let rule = Rule::::Email; + let rule = Rule::::Email(Default::default()); let compiled = rule.compile(); assert!(compiled.validate_str("user@example.com").is_ok()); diff --git a/crates/validation/src/rule_impls/value.rs b/crates/validation/src/rule_impls/value.rs index e1c4efb..ab5fa79 100644 --- a/crates/validation/src/rule_impls/value.rs +++ b/crates/validation/src/rule_impls/value.rs @@ -114,8 +114,8 @@ impl Rule { "Expected a string for Pattern.", )), }, - Rule::Email => match value { - Value::Str(s) => Rule::::Email.validate_str(s.as_str()), + Rule::Email(opts) => match value { + Value::Str(s) => Rule::::Email(opts.clone()).validate_str(s.as_str()), _ => Err(Violation::new( ViolationType::TypeMismatch, "Expected a string for Email.", @@ -149,6 +149,20 @@ impl Rule { "Expected a string for Hostname.", )), }, + Rule::Date(opts) => match value { + Value::Str(s) => Rule::::Date(opts.clone()).validate_str(s.as_str()), + _ => Err(Violation::new( + ViolationType::TypeMismatch, + "Expected a string for Date.", + )), + }, + Rule::DateRange(opts) => match value { + Value::Str(s) => Rule::::DateRange(opts.clone()).validate_str(s.as_str()), + _ => Err(Violation::new( + ViolationType::TypeMismatch, + "Expected a string for DateRange.", + )), + }, // ---- Numeric rules ---- Rule::Min(bound) => match value.partial_cmp(bound) { @@ -368,7 +382,7 @@ mod tests { #[test] fn test_email() { - let rule = Rule::::Email; + let rule = Rule::::Email(Default::default()); assert!(rule.validate_value(&Value::Str("test@example.com".to_string())).is_ok()); assert!(rule.validate_value(&Value::Str("invalid".to_string())).is_err()); } @@ -458,7 +472,7 @@ mod tests { #[test] fn test_any() { let rule = Rule::::Any(vec![ - Rule::Email, + Rule::Email(Default::default()), Rule::Url(Default::default()), ]); assert!(rule.validate_value(&Value::Str("test@example.com".to_string())).is_ok()); diff --git a/crates/validation/src/violation.rs b/crates/validation/src/violation.rs index 30a7953..73bda60 100644 --- a/crates/validation/src/violation.rs +++ b/crates/validation/src/violation.rs @@ -111,6 +111,27 @@ impl Violation { Self::new(ViolationType::TypeMismatch, "Invalid hostname.") } + /// Value is not a valid date. + pub fn invalid_date() -> Self { + Self::new(ViolationType::TypeMismatch, "Invalid date.") + } + + /// Date is before the allowed minimum. + pub fn date_range_underflow(min: &str) -> Self { + Self::new( + ViolationType::RangeUnderflow, + format!("Date must be on or after {}.", min), + ) + } + + /// Date exceeds the allowed maximum. + pub fn date_range_overflow(max: &str) -> Self { + Self::new( + ViolationType::RangeOverflow, + format!("Date must be on or before {}.", max), + ) + } + /// Value is below the allowed minimum. pub fn range_underflow(min: &T) -> Self { Self::new(