Skip to main content

rainier_validation/
rule.rs

1//! Validation rules — [`Rule`] and the built-in set.
2//!
3//! A rule inspects one field's value and either passes or explains why not.
4//! Rules are values rather than strings (`Rule::Email`, not `"email"`), so a
5//! typo is a compile error and a rule that takes an argument takes a *typed*
6//! argument.
7//!
8//! ## Present, null, and empty are three different things
9//!
10//! This trips people up in every validation library, so the rules here are
11//! explicit about it:
12//!
13//! - **Absent** — the key is not in the input at all.
14//! - **Null** — the key is present with a `null` value (which is what
15//!   `ConvertEmptyStringsToNull` turns a blank text input into).
16//! - **Empty** — present, non-null, but `""` or `[]`.
17//!
18//! [`Rule::Required`] rejects all three. Every *other* rule **skips** absent
19//! and null values, so `Rule::Email` on an optional field does not fire when
20//! the field was left out. That is what makes `[Rule::Email]` mean "if
21//! supplied, it must be an email" and `[Rule::Required, Rule::Email]` mean
22//! "must be supplied, and must be an email".
23
24use std::sync::Arc;
25
26use chrono::NaiveDate;
27use serde_json::Value;
28
29/// What a rule concluded about one value.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Outcome {
32    /// The value is acceptable.
33    Pass,
34    /// The value is not acceptable, for this reason.
35    Fail(String),
36    /// The value is absent or null and this rule has nothing to say, so the
37    /// remaining rules for the field should be skipped too.
38    Skip,
39}
40
41/// One validation rule.
42#[derive(Clone)]
43pub enum Rule {
44    /// Present, not null, and not empty.
45    Required,
46    /// Present (may be null or empty).
47    Present,
48    /// Not null when present.
49    NotNull,
50    /// A string (or a value that reads as one).
51    String,
52    /// An integer.
53    Integer,
54    /// A number, integer or not.
55    Numeric,
56    /// A boolean, or one of the strings a form sends for one.
57    Boolean,
58    /// An array.
59    Array,
60    /// At least this length: characters for a string, elements for an array,
61    /// value for a number.
62    Min(f64),
63    /// At most this length, by the same measure as [`Rule::Min`].
64    Max(f64),
65    /// Exactly this length.
66    Size(f64),
67    /// Between these bounds, inclusive.
68    Between(f64, f64),
69    /// A plausible email address.
70    Email,
71    /// An absolute `http`/`https` URL.
72    Url,
73    /// Letters, digits, `-` and `_`.
74    Slug,
75    /// A canonical UUID.
76    Uuid,
77    /// ASCII letters only.
78    Alpha,
79    /// ASCII letters and digits.
80    AlphaNumeric,
81    /// Letters, digits, `-` and `_`.
82    AlphaDash,
83    /// One of these values.
84    In(Vec<String>),
85    /// None of these values.
86    NotIn(Vec<String>),
87    /// Starts with this prefix.
88    StartsWith(String),
89    /// Ends with this suffix.
90    EndsWith(String),
91    /// A `YYYY-MM-DD` date.
92    Date,
93    /// A date on or after this one.
94    After(NaiveDate),
95    /// A date on or before this one.
96    Before(NaiveDate),
97    /// Equal to another field's value — `password_confirmation`.
98    Confirmed,
99    /// Equal to the named field's value.
100    Same(String),
101    /// Different from the named field's value.
102    Different(String),
103    /// Any predicate, with the message to use when it fails.
104    Custom {
105        /// Shown when the predicate returns `false`.
106        message: String,
107        /// The predicate.
108        check: Arc<dyn Fn(&Value) -> bool + Send + Sync>,
109    },
110}
111
112impl std::fmt::Debug for Rule {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Rule::Custom { message, .. } => write!(f, "Custom({message:?})"),
116            other => f.write_str(other.name()),
117        }
118    }
119}
120
121impl Rule {
122    /// The rule's name, as it appears in a failure message.
123    pub fn name(&self) -> &'static str {
124        match self {
125            Rule::Required => "required",
126            Rule::Present => "present",
127            Rule::NotNull => "not_null",
128            Rule::String => "string",
129            Rule::Integer => "integer",
130            Rule::Numeric => "numeric",
131            Rule::Boolean => "boolean",
132            Rule::Array => "array",
133            Rule::Min(_) => "min",
134            Rule::Max(_) => "max",
135            Rule::Size(_) => "size",
136            Rule::Between(_, _) => "between",
137            Rule::Email => "email",
138            Rule::Url => "url",
139            Rule::Slug => "slug",
140            Rule::Uuid => "uuid",
141            Rule::Alpha => "alpha",
142            Rule::AlphaNumeric => "alpha_num",
143            Rule::AlphaDash => "alpha_dash",
144            Rule::In(_) => "in",
145            Rule::NotIn(_) => "not_in",
146            Rule::StartsWith(_) => "starts_with",
147            Rule::EndsWith(_) => "ends_with",
148            Rule::Date => "date",
149            Rule::After(_) => "after",
150            Rule::Before(_) => "before",
151            Rule::Confirmed => "confirmed",
152            Rule::Same(_) => "same",
153            Rule::Different(_) => "different",
154            Rule::Custom { .. } => "custom",
155        }
156    }
157
158    /// Whether this rule cares about a value that is absent or null.
159    ///
160    /// Only the presence rules do. Everything else defers to them, which is
161    /// what makes optional fields work without a rule combinator.
162    pub fn applies_to_missing(&self) -> bool {
163        matches!(self, Rule::Required | Rule::Present | Rule::NotNull)
164    }
165
166    /// Check `value` (the field being validated) against this rule.
167    ///
168    /// `field` names it for the message, and `all` is the whole input, which
169    /// the cross-field rules need.
170    pub fn check(&self, field: &str, value: Option<&Value>, all: &Value) -> Outcome {
171        // Presence first: every other rule sits this one out.
172        match self {
173            Rule::Required => {
174                return if is_filled(value) {
175                    Outcome::Pass
176                } else {
177                    Outcome::Fail(format!("The {} field is required.", label(field)))
178                };
179            }
180            Rule::Present => {
181                return if value.is_some() {
182                    Outcome::Pass
183                } else {
184                    Outcome::Fail(format!("The {} field must be present.", label(field)))
185                };
186            }
187            Rule::NotNull => {
188                return match value {
189                    Some(Value::Null) => {
190                        Outcome::Fail(format!("The {} field must not be null.", label(field)))
191                    }
192                    _ => Outcome::Pass,
193                };
194            }
195            _ => {}
196        }
197
198        let Some(value) = value else { return Outcome::Skip };
199        if value.is_null() {
200            return Outcome::Skip;
201        }
202
203        let name = label(field);
204        match self {
205            Rule::Required | Rule::Present | Rule::NotNull => Outcome::Pass,
206
207            Rule::String => check(value.is_string(), format!("The {name} field must be a string.")),
208            Rule::Integer => {
209                check(as_integer(value).is_some(), format!("The {name} field must be an integer."))
210            }
211            Rule::Numeric => {
212                check(as_number(value).is_some(), format!("The {name} field must be a number."))
213            }
214            Rule::Boolean => {
215                check(as_bool(value).is_some(), format!("The {name} field must be true or false."))
216            }
217            Rule::Array => check(value.is_array(), format!("The {name} field must be an array.")),
218
219            Rule::Min(min) => match measure(value) {
220                Measure::Number(n) => check(
221                    n >= *min,
222                    format!("The {name} field must be at least {}.", trim_float(*min)),
223                ),
224                Measure::Length(len) => check(
225                    len as f64 >= *min,
226                    format!(
227                        "The {name} field must be at least {} {}.",
228                        trim_float(*min),
229                        unit(value)
230                    ),
231                ),
232            },
233            Rule::Max(max) => match measure(value) {
234                Measure::Number(n) => check(
235                    n <= *max,
236                    format!("The {name} field must not be greater than {}.", trim_float(*max)),
237                ),
238                Measure::Length(len) => check(
239                    len as f64 <= *max,
240                    format!(
241                        "The {name} field must not be greater than {} {}.",
242                        trim_float(*max),
243                        unit(value)
244                    ),
245                ),
246            },
247            Rule::Size(size) => match measure(value) {
248                Measure::Number(n) => check(
249                    (n - *size).abs() < f64::EPSILON,
250                    format!("The {name} field must be {}.", trim_float(*size)),
251                ),
252                Measure::Length(len) => check(
253                    len as f64 == *size,
254                    format!("The {name} field must be {} {}.", trim_float(*size), unit(value)),
255                ),
256            },
257            Rule::Between(low, high) => {
258                let actual = match measure(value) {
259                    Measure::Number(n) => n,
260                    Measure::Length(len) => len as f64,
261                };
262                check(
263                    actual >= *low && actual <= *high,
264                    format!(
265                        "The {name} field must be between {} and {}.",
266                        trim_float(*low),
267                        trim_float(*high)
268                    ),
269                )
270            }
271
272            Rule::Email => check(
273                text(value).is_some_and(|v| is_email(&v)),
274                format!("The {name} field must be a valid email address."),
275            ),
276            Rule::Url => check(
277                text(value).is_some_and(|v| is_url(&v)),
278                format!("The {name} field must be a valid URL."),
279            ),
280            Rule::Slug => check(
281                text(value).is_some_and(|v| {
282                    !v.is_empty()
283                        && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
284                }),
285                format!("The {name} field must be a valid slug."),
286            ),
287            Rule::Uuid => check(
288                text(value).is_some_and(|v| is_uuid(&v)),
289                format!("The {name} field must be a valid UUID."),
290            ),
291            Rule::Alpha => check(
292                text(value)
293                    .is_some_and(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_alphabetic())),
294                format!("The {name} field must only contain letters."),
295            ),
296            Rule::AlphaNumeric => check(
297                text(value)
298                    .is_some_and(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_alphanumeric())),
299                format!("The {name} field must only contain letters and numbers."),
300            ),
301            Rule::AlphaDash => check(
302                text(value).is_some_and(|v| {
303                    !v.is_empty()
304                        && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
305                }),
306                format!(
307                    "The {name} field must only contain letters, numbers, dashes and underscores."
308                ),
309            ),
310
311            Rule::In(allowed) => check(
312                text(value).is_some_and(|v| allowed.contains(&v)),
313                format!("The selected {name} is invalid."),
314            ),
315            Rule::NotIn(denied) => check(
316                text(value).is_some_and(|v| !denied.contains(&v)),
317                format!("The selected {name} is invalid."),
318            ),
319            Rule::StartsWith(prefix) => check(
320                text(value).is_some_and(|v| v.starts_with(prefix.as_str())),
321                format!("The {name} field must start with {prefix}."),
322            ),
323            Rule::EndsWith(suffix) => check(
324                text(value).is_some_and(|v| v.ends_with(suffix.as_str())),
325                format!("The {name} field must end with {suffix}."),
326            ),
327
328            Rule::Date => check(
329                text(value).is_some_and(|v| parse_date(&v).is_some()),
330                format!("The {name} field must be a valid date."),
331            ),
332            Rule::After(bound) => check(
333                text(value).and_then(|v| parse_date(&v)).is_some_and(|d| d > *bound),
334                format!("The {name} field must be a date after {bound}."),
335            ),
336            Rule::Before(bound) => check(
337                text(value).and_then(|v| parse_date(&v)).is_some_and(|d| d < *bound),
338                format!("The {name} field must be a date before {bound}."),
339            ),
340
341            Rule::Confirmed => {
342                let other = format!("{field}_confirmation");
343                check(
344                    crate::lookup(all, &other) == Some(value),
345                    format!("The {name} field confirmation does not match."),
346                )
347            }
348            Rule::Same(other) => check(
349                crate::lookup(all, other) == Some(value),
350                format!("The {name} field must match {}.", label(other)),
351            ),
352            Rule::Different(other) => check(
353                crate::lookup(all, other) != Some(value),
354                format!("The {name} field must be different from {}.", label(other)),
355            ),
356
357            Rule::Custom { message, check: predicate } => check(predicate(value), message.clone()),
358        }
359    }
360}
361
362fn check(passed: bool, message: String) -> Outcome {
363    if passed {
364        Outcome::Pass
365    } else {
366        Outcome::Fail(message)
367    }
368}
369
370/// Whether a value counts as "filled" for [`Rule::Required`].
371fn is_filled(value: Option<&Value>) -> bool {
372    match value {
373        None | Some(Value::Null) => false,
374        Some(Value::String(s)) => !s.trim().is_empty(),
375        Some(Value::Array(items)) => !items.is_empty(),
376        Some(Value::Object(map)) => !map.is_empty(),
377        Some(_) => true,
378    }
379}
380
381/// What [`Rule::Min`] and friends measure: a number's magnitude, or a string's
382/// or array's length.
383enum Measure {
384    Number(f64),
385    Length(usize),
386}
387
388fn measure(value: &Value) -> Measure {
389    match value {
390        Value::Number(n) => Measure::Number(n.as_f64().unwrap_or(0.0)),
391        Value::String(s) => match s.parse::<f64>() {
392            // A numeric string is measured as a number, so `min:18` on a form
393            // field means "at least eighteen", not "at least two digits".
394            Ok(n) => Measure::Number(n),
395            Err(_) => Measure::Length(s.chars().count()),
396        },
397        Value::Array(items) => Measure::Length(items.len()),
398        Value::Object(map) => Measure::Length(map.len()),
399        Value::Bool(_) | Value::Null => Measure::Length(0),
400    }
401}
402
403fn unit(value: &Value) -> &'static str {
404    match value {
405        Value::Array(_) | Value::Object(_) => "items",
406        _ => "characters",
407    }
408}
409
410fn trim_float(value: f64) -> String {
411    if value.fract() == 0.0 {
412        format!("{}", value as i64)
413    } else {
414        format!("{value}")
415    }
416}
417
418/// A field's human-readable name: `email_address` → `email address`.
419fn label(field: &str) -> String {
420    field.replace(['_', '.'], " ")
421}
422
423fn text(value: &Value) -> Option<String> {
424    match value {
425        Value::String(s) => Some(s.clone()),
426        Value::Number(n) => Some(n.to_string()),
427        Value::Bool(b) => Some(b.to_string()),
428        _ => None,
429    }
430}
431
432fn as_number(value: &Value) -> Option<f64> {
433    match value {
434        Value::Number(n) => n.as_f64(),
435        Value::String(s) => s.trim().parse().ok(),
436        _ => None,
437    }
438}
439
440fn as_integer(value: &Value) -> Option<i64> {
441    match value {
442        Value::Number(n) => n.as_i64(),
443        Value::String(s) => s.trim().parse().ok(),
444        _ => None,
445    }
446}
447
448fn as_bool(value: &Value) -> Option<bool> {
449    match value {
450        Value::Bool(b) => Some(*b),
451        Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
452            "true" | "1" | "on" | "yes" => Some(true),
453            "false" | "0" | "off" | "no" => Some(false),
454            _ => None,
455        },
456        Value::Number(n) => n
457            .as_i64()
458            .map(|i| i == 0 || i == 1)
459            .and_then(|ok| ok.then(|| n.as_i64().expect("just read") != 0)),
460        _ => None,
461    }
462}
463
464/// A pragmatic email check.
465///
466/// Deliberately **not** RFC 5322: that grammar accepts addresses no mail
467/// provider will, and rejecting a valid-but-unusual address annoys a real user
468/// far more than accepting an invalid one costs. The bar here is "one `@`, a
469/// non-empty local part, and a domain with a dot" — after that, send a
470/// confirmation email, which is the only real check.
471fn is_email(value: &str) -> bool {
472    let Some((local, domain)) = value.split_once('@') else {
473        return false;
474    };
475    if local.is_empty() || domain.is_empty() || value.contains(' ') {
476        return false;
477    }
478    if domain.contains('@') || domain.starts_with('.') || domain.ends_with('.') {
479        return false;
480    }
481    match domain.split_once('.') {
482        Some((head, tail)) => !head.is_empty() && !tail.is_empty() && !tail.contains(' '),
483        None => false,
484    }
485}
486
487fn is_url(value: &str) -> bool {
488    let rest = value.strip_prefix("https://").or_else(|| value.strip_prefix("http://"));
489    match rest {
490        Some(rest) => !rest.is_empty() && !rest.starts_with('/') && !rest.contains(' '),
491        None => false,
492    }
493}
494
495fn is_uuid(value: &str) -> bool {
496    let groups = [8, 4, 4, 4, 12];
497    let mut parts = value.split('-');
498    for expected in groups {
499        let Some(part) = parts.next() else { return false };
500        if part.len() != expected || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
501            return false;
502        }
503    }
504    parts.next().is_none()
505}
506
507fn parse_date(value: &str) -> Option<NaiveDate> {
508    NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").ok()
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use serde_json::json;
515
516    fn run(rule: &Rule, value: Value) -> Outcome {
517        let all = json!({});
518        rule.check("field", Some(&value), &all)
519    }
520
521    fn passes(rule: &Rule, value: Value) -> bool {
522        run(rule, value) == Outcome::Pass
523    }
524
525    fn message(rule: &Rule, field: &str, value: Value) -> String {
526        match rule.check(field, Some(&value), &json!({})) {
527            Outcome::Fail(message) => message,
528            other => panic!("expected a failure, got {other:?}"),
529        }
530    }
531
532    #[test]
533    fn required_rejects_absent_null_and_blank() {
534        let all = json!({});
535        assert!(matches!(Rule::Required.check("f", None, &all), Outcome::Fail(_)));
536        assert!(matches!(Rule::Required.check("f", Some(&json!(null)), &all), Outcome::Fail(_)));
537        assert!(matches!(Rule::Required.check("f", Some(&json!("")), &all), Outcome::Fail(_)));
538        assert!(matches!(Rule::Required.check("f", Some(&json!("   ")), &all), Outcome::Fail(_)));
539        assert!(matches!(Rule::Required.check("f", Some(&json!([])), &all), Outcome::Fail(_)));
540        assert_eq!(Rule::Required.check("f", Some(&json!("x")), &all), Outcome::Pass);
541        assert_eq!(Rule::Required.check("f", Some(&json!(0)), &all), Outcome::Pass);
542        assert_eq!(Rule::Required.check("f", Some(&json!(false)), &all), Outcome::Pass);
543    }
544
545    #[test]
546    fn present_only_asks_whether_the_key_exists() {
547        let all = json!({});
548        assert!(matches!(Rule::Present.check("f", None, &all), Outcome::Fail(_)));
549        assert_eq!(Rule::Present.check("f", Some(&json!(null)), &all), Outcome::Pass);
550        assert_eq!(Rule::Present.check("f", Some(&json!("")), &all), Outcome::Pass);
551    }
552
553    #[test]
554    fn other_rules_skip_absent_and_null_values() {
555        let all = json!({});
556        assert_eq!(Rule::Email.check("f", None, &all), Outcome::Skip);
557        assert_eq!(Rule::Email.check("f", Some(&json!(null)), &all), Outcome::Skip);
558        assert_eq!(Rule::Integer.check("f", None, &all), Outcome::Skip);
559    }
560
561    #[test]
562    fn type_rules() {
563        assert!(passes(&Rule::String, json!("x")));
564        assert!(!passes(&Rule::String, json!(1)));
565
566        assert!(passes(&Rule::Integer, json!(5)));
567        assert!(passes(&Rule::Integer, json!("5")), "a form sends numbers as strings");
568        assert!(!passes(&Rule::Integer, json!("5.5")));
569        assert!(!passes(&Rule::Integer, json!("abc")));
570
571        assert!(passes(&Rule::Numeric, json!("5.5")));
572        assert!(passes(&Rule::Numeric, json!(5)));
573        assert!(!passes(&Rule::Numeric, json!("abc")));
574
575        assert!(passes(&Rule::Boolean, json!(true)));
576        assert!(passes(&Rule::Boolean, json!("on")));
577        assert!(passes(&Rule::Boolean, json!("0")));
578        assert!(!passes(&Rule::Boolean, json!("maybe")));
579
580        assert!(passes(&Rule::Array, json!([1])));
581        assert!(!passes(&Rule::Array, json!("x")));
582    }
583
584    #[test]
585    fn size_rules_measure_numbers_by_value_and_text_by_length() {
586        assert!(passes(&Rule::Min(3.0), json!("abc")));
587        assert!(!passes(&Rule::Min(3.0), json!("ab")));
588
589        // A numeric string is measured as a number: `min:18` on an age field.
590        assert!(passes(&Rule::Min(18.0), json!("21")));
591        assert!(!passes(&Rule::Min(18.0), json!("17")));
592
593        assert!(passes(&Rule::Max(2.0), json!([1, 2])));
594        assert!(!passes(&Rule::Max(2.0), json!([1, 2, 3])));
595
596        assert!(passes(&Rule::Size(4.0), json!("abcd")));
597        assert!(passes(&Rule::Between(1.0, 3.0), json!("ab")));
598        assert!(!passes(&Rule::Between(1.0, 3.0), json!("abcd")));
599    }
600
601    #[test]
602    fn size_messages_name_the_right_unit() {
603        assert!(message(&Rule::Max(2.0), "tags", json!([1, 2, 3])).contains("2 items"));
604        assert!(message(&Rule::Max(2.0), "name", json!("abc")).contains("2 characters"));
605    }
606
607    #[test]
608    fn email_accepts_the_ordinary_and_rejects_the_obviously_broken() {
609        for good in ["a@b.co", "first.last+tag@sub.example.com", "x@y.z"] {
610            assert!(passes(&Rule::Email, json!(good)), "{good} should pass");
611        }
612        for bad in ["", "plain", "@b.co", "a@", "a@b", "a b@c.co", "a@b@c.co", "a@.co", "a@b."] {
613            assert!(!passes(&Rule::Email, json!(bad)), "{bad} should fail");
614        }
615    }
616
617    #[test]
618    fn url_requires_a_scheme_and_a_host() {
619        assert!(passes(&Rule::Url, json!("https://example.com")));
620        assert!(passes(&Rule::Url, json!("http://example.com/a?b=1")));
621        assert!(!passes(&Rule::Url, json!("example.com")));
622        assert!(!passes(&Rule::Url, json!("https://")));
623        assert!(!passes(&Rule::Url, json!("ftp://example.com")));
624        assert!(!passes(&Rule::Url, json!("https:// example.com")));
625    }
626
627    #[test]
628    fn character_class_rules() {
629        assert!(passes(&Rule::Alpha, json!("abc")));
630        assert!(!passes(&Rule::Alpha, json!("a1")));
631        assert!(passes(&Rule::AlphaNumeric, json!("a1")));
632        assert!(!passes(&Rule::AlphaNumeric, json!("a-1")));
633        assert!(passes(&Rule::AlphaDash, json!("a-1_b")));
634        assert!(!passes(&Rule::AlphaDash, json!("a b")));
635        assert!(passes(&Rule::Slug, json!("hello-world")));
636        assert!(!passes(&Rule::Slug, json!("hello world")));
637        assert!(passes(&Rule::Uuid, json!("123e4567-e89b-12d3-a456-426614174000")));
638        assert!(!passes(&Rule::Uuid, json!("nope")));
639    }
640
641    #[test]
642    fn membership_rules() {
643        let allowed = Rule::In(vec!["draft".into(), "live".into()]);
644        assert!(passes(&allowed, json!("draft")));
645        assert!(!passes(&allowed, json!("deleted")));
646
647        let denied = Rule::NotIn(vec!["admin".into()]);
648        assert!(passes(&denied, json!("user")));
649        assert!(!passes(&denied, json!("admin")));
650
651        assert!(passes(&Rule::StartsWith("pk_".into()), json!("pk_123")));
652        assert!(!passes(&Rule::StartsWith("pk_".into()), json!("sk_123")));
653        assert!(passes(&Rule::EndsWith(".png".into()), json!("a.png")));
654    }
655
656    #[test]
657    fn date_rules() {
658        assert!(passes(&Rule::Date, json!("2026-07-25")));
659        assert!(!passes(&Rule::Date, json!("25/07/2026")));
660        assert!(!passes(&Rule::Date, json!("2026-13-01")));
661
662        let bound = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
663        assert!(passes(&Rule::After(bound), json!("2026-07-25")));
664        assert!(!passes(&Rule::After(bound), json!("2025-12-31")));
665        assert!(!passes(&Rule::After(bound), json!("2026-01-01")), "after is exclusive");
666        assert!(passes(&Rule::Before(bound), json!("2025-12-31")));
667    }
668
669    #[test]
670    fn cross_field_rules_read_the_whole_input() {
671        let all = json!({ "password": "secret", "password_confirmation": "secret", "other": "x" });
672
673        assert_eq!(Rule::Confirmed.check("password", Some(&json!("secret")), &all), Outcome::Pass);
674
675        let mismatched = json!({ "password_confirmation": "different" });
676        assert!(matches!(
677            Rule::Confirmed.check("password", Some(&json!("secret")), &mismatched),
678            Outcome::Fail(_)
679        ));
680
681        assert_eq!(
682            Rule::Same("password".into()).check("f", Some(&json!("secret")), &all),
683            Outcome::Pass
684        );
685        assert_eq!(
686            Rule::Different("other".into()).check("f", Some(&json!("y")), &all),
687            Outcome::Pass
688        );
689        assert!(matches!(
690            Rule::Different("other".into()).check("f", Some(&json!("x")), &all),
691            Outcome::Fail(_)
692        ));
693    }
694
695    #[test]
696    fn confirmed_fails_when_the_confirmation_is_absent() {
697        let all = json!({ "password": "secret" });
698        assert!(matches!(
699            Rule::Confirmed.check("password", Some(&json!("secret")), &all),
700            Outcome::Fail(_)
701        ));
702    }
703
704    #[test]
705    fn a_custom_rule_supplies_its_own_message() {
706        let rule = Rule::Custom {
707            message: "The code must start with X.".into(),
708            check: Arc::new(|value| value.as_str().is_some_and(|s| s.starts_with('X'))),
709        };
710        assert!(passes(&rule, json!("X1")));
711        assert_eq!(message(&rule, "code", json!("Y1")), "The code must start with X.");
712    }
713
714    #[test]
715    fn messages_humanise_the_field_name() {
716        assert_eq!(
717            message(&Rule::Required, "email_address", json!(null)),
718            "The email address field is required."
719        );
720    }
721}