Skip to main content

taut_rpc/
validate.rs

1//! Validation bridge for taut-rpc. See SPEC §7.
2//!
3//! Per SPEC §7, `#[derive(Validate)]` on input/output types records a per-field
4//! schema description into the IR; codegen then emits a Valibot (or, opt-in, Zod)
5//! schema on the TypeScript side. Generated clients validate inputs *before*
6//! sending and outputs *after* receiving by default; both checks can be disabled
7//! per-call.
8//!
9//! This module defines the public surface of that bridge:
10//!
11//! - The [`Validate`] trait that the derive (Phase 4) implements.
12//! - The [`ValidationError`] type returned by failed checks.
13//! - The [`Constraint`] vocabulary recorded into the IR. SPEC §7 fixes the 0.1
14//!   set as `min`, `max`, `length`, `pattern`, `email`, `url`, plus opaque
15//!   `custom` predicates that require user-supplied schema fragments.
16//! - A [`check`] sub-module of stand-alone validators that don't need a derive.
17//! - The [`run`], [`collect`], and [`nested`] glue helpers that the
18//!   `#[derive(Validate)]` macro lowers its generated code to. The macro emits
19//!   one `validate::run(|errors| { ... })` per impl, with a chain of
20//!   `validate::collect(errors, || check::xxx(...))` calls inside, plus
21//!   `validate::nested(errors, "field", &self.field)` for nested types that
22//!   themselves implement [`Validate`]. Keeping this glue in the runtime crate
23//!   instead of inlining it into each derive output keeps macro emissions
24//!   small, easier to read, and easier to evolve.
25//!
26//! # Status
27//!
28//! Phase 4 of the ROADMAP. The trait, error type, constraint enum, and
29//! free-standing checkers are stable; the `#[derive(Validate)]` proc-macro
30//! lowers to the helpers in this module.
31
32use serde::{Deserialize, Serialize};
33
34/// User-facing validation trait.
35///
36/// `#[derive(Validate)]` (Phase 4) implements this by walking the type's
37/// fields and dispatching to the [`check`] helpers in this module via
38/// [`run`] + [`collect`] + [`nested`]. Hand-written impls are also supported.
39///
40/// # Examples
41///
42/// Most users derive `Validate` and annotate fields with the constraint
43/// vocabulary from SPEC §7:
44///
45/// ```rust,ignore
46/// use taut_rpc::Validate;
47///
48/// #[derive(Validate)]
49/// pub struct CreateUser {
50///     #[validate(length(min = 1, max = 64))]
51///     pub name: String,
52///     #[validate(email)]
53///     pub email: String,
54///     #[validate(min = 0.0, max = 150.0)]
55///     pub age: u32,
56/// }
57/// ```
58pub trait Validate {
59    /// Validate `self`, returning every collected failure.
60    ///
61    /// Returns `Ok(())` if the value is valid. Otherwise returns a non-empty
62    /// `Vec<ValidationError>`; implementations should accumulate all failures
63    /// rather than short-circuiting on the first one.
64    fn validate(&self) -> Result<(), Vec<ValidationError>>;
65}
66
67/// One reported validation failure.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
69#[error("{path}: {message}")]
70pub struct ValidationError {
71    /// Dotted path to the field (e.g. `"user.email"`). Empty string for
72    /// root-level errors.
73    pub path: String,
74    /// Constraint name that was violated (matches [`Constraint`] variants below,
75    /// in `snake_case`).
76    pub constraint: String,
77    /// Human-readable message.
78    pub message: String,
79}
80
81impl ValidationError {
82    /// Construct a `ValidationError`. All fields accept anything `Into<String>`.
83    pub fn new(
84        path: impl Into<String>,
85        constraint: impl Into<String>,
86        message: impl Into<String>,
87    ) -> Self {
88        Self {
89            path: path.into(),
90            constraint: constraint.into(),
91            message: message.into(),
92        }
93    }
94}
95
96/// Constraint vocabulary recorded into the IR. SPEC §7 lists the supported set
97/// for 0.1.
98///
99/// The `serde` representation is the externally-tagged form
100/// `{ "kind": "<snake_case>", "value": ... }` so that codegen on the
101/// TypeScript side can pattern-match cleanly.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
104pub enum Constraint {
105    /// Numeric lower bound (inclusive).
106    Min(f64),
107    /// Numeric upper bound (inclusive).
108    Max(f64),
109    /// String length range: `length(min, max?)`.
110    Length {
111        /// Inclusive minimum length, if any.
112        min: Option<u32>,
113        /// Inclusive maximum length, if any.
114        max: Option<u32>,
115    },
116    /// Regex pattern (uncompiled — codegen forwards to JS `RegExp` /
117    /// Valibot `regex`).
118    Pattern(String),
119    /// RFC-5322-ish email check. The runtime check is deliberately permissive;
120    /// the canonical validation happens in the codegen schema.
121    Email,
122    /// `http://` or `https://` URL check.
123    Url,
124    /// Opaque tag for user-supplied predicates; codegen requires a
125    /// user-supplied schema fragment to honour the constraint.
126    Custom(String),
127}
128
129/// Built-in validators that don't need a derive.
130///
131/// Each function returns `Ok(())` on success or a [`ValidationError`] whose
132/// `constraint` field is the name of the failed check (matching the
133/// `snake_case` [`Constraint`] tag). The `path` argument is forwarded
134/// verbatim into the error so callers can plumb through dotted field paths.
135pub mod check {
136    use super::ValidationError;
137
138    /// Truncate a user-supplied string for inclusion in error messages.
139    ///
140    /// Long inputs (think pasted blobs) would otherwise blow up the
141    /// `message` field. Take the first 20 chars and append `…` if the input
142    /// is longer. Counts Unicode scalar values, not bytes, so we never split
143    /// a multibyte codepoint.
144    fn truncate_for_message(s: &str) -> String {
145        const LIMIT: usize = 20;
146        let mut iter = s.chars();
147        let head: String = iter.by_ref().take(LIMIT).collect();
148        if iter.next().is_some() {
149            format!("{head}…")
150        } else {
151            head
152        }
153    }
154
155    /// Numeric lower bound (inclusive). `value == min` passes.
156    ///
157    /// Generic over any numeric primitive that lossslessly converts into `f64`
158    /// so that the `#[derive(Validate)]` macro can emit
159    /// `check::min("x", self.x, 1.0)` regardless of whether `self.x` is an
160    /// `i32`, `u8`, `u64`, `f32`, ...
161    ///
162    /// On failure the error `message` is `"value {v} is less than minimum
163    /// {min}"`, where `{v}` is the offending value formatted as `f64`. The
164    /// field path is *not* embedded in the message: the `path` field already
165    /// holds it, and UIs are expected to render `path: message`.
166    pub fn min<T>(path: &str, value: T, min: f64) -> Result<(), ValidationError>
167    where
168        T: Into<f64> + Copy,
169    {
170        let v: f64 = value.into();
171        if v < min {
172            Err(ValidationError::new(
173                path,
174                "min",
175                format!("value {v} is less than minimum {min}"),
176            ))
177        } else {
178            Ok(())
179        }
180    }
181
182    /// Numeric upper bound (inclusive). `value == max` passes.
183    ///
184    /// Generic over any numeric primitive that lossslessly converts into `f64`
185    /// (see [`min`] for the rationale).
186    ///
187    /// On failure the error `message` is `"value {v} is greater than maximum
188    /// {max}"`, formatted as `f64`. The field path is not embedded.
189    pub fn max<T>(path: &str, value: T, max: f64) -> Result<(), ValidationError>
190    where
191        T: Into<f64> + Copy,
192    {
193        let v: f64 = value.into();
194        if v > max {
195            Err(ValidationError::new(
196                path,
197                "max",
198                format!("value {v} is greater than maximum {max}"),
199            ))
200        } else {
201            Ok(())
202        }
203    }
204
205    /// String length range. Bounds are character counts (Unicode scalar values),
206    /// not bytes. Either bound may be omitted.
207    ///
208    /// On failure the error `message` is `"length {n} is outside [{lo},
209    /// {hi}]"`, with `{lo}` rendered as the integer minimum or `"no
210    /// minimum"`, and `{hi}` rendered as the integer maximum or `"no
211    /// maximum"`.
212    pub fn length(
213        path: &str,
214        s: &str,
215        min: Option<u32>,
216        max: Option<u32>,
217    ) -> Result<(), ValidationError> {
218        let len = s.chars().count() as u64;
219        let out_of_range = match (min, max) {
220            (Some(lo), _) if len < u64::from(lo) => true,
221            (_, Some(hi)) if len > u64::from(hi) => true,
222            _ => false,
223        };
224        if !out_of_range {
225            return Ok(());
226        }
227        let lo = min.map_or_else(|| "no minimum".to_string(), |n| n.to_string());
228        let hi = max.map_or_else(|| "no maximum".to_string(), |n| n.to_string());
229        Err(ValidationError::new(
230            path,
231            "length",
232            format!("length {len} is outside [{lo}, {hi}]"),
233        ))
234    }
235
236    /// Permissive email check: requires an `@`, with at least one character
237    /// before it and a `.` somewhere after it (also with at least one character
238    /// after the dot). The canonical validation is done by the generated
239    /// TypeScript schema.
240    ///
241    /// On failure the error `message` is `"not a valid email: {value}"`,
242    /// where `{value}` is the offending input truncated to 20 chars.
243    pub fn email(path: &str, s: &str) -> Result<(), ValidationError> {
244        let bad = || {
245            ValidationError::new(
246                path,
247                "email",
248                format!("not a valid email: {}", truncate_for_message(s)),
249            )
250        };
251        let at = s.find('@').ok_or_else(bad)?;
252        if at == 0 {
253            return Err(bad());
254        }
255        let after_at = &s[at + 1..];
256        let dot = after_at.find('.').ok_or_else(bad)?;
257        if dot == 0 || dot + 1 >= after_at.len() {
258            return Err(bad());
259        }
260        Ok(())
261    }
262
263    /// URL check: must start with `http://` or `https://`.
264    ///
265    /// On failure the error `message` is `"not a valid url: {value}"`, with
266    /// the offending input truncated to 20 chars.
267    pub fn url(path: &str, s: &str) -> Result<(), ValidationError> {
268        if s.starts_with("http://") || s.starts_with("https://") {
269            Ok(())
270        } else {
271            Err(ValidationError::new(
272                path,
273                "url",
274                format!("not a valid url: {}", truncate_for_message(s)),
275            ))
276        }
277    }
278
279    /// Regex pattern check.
280    ///
281    /// Compiles `regex_src` against the `regex` crate and tests whether `s`
282    /// matches anywhere in the input (i.e. uses `Regex::is_match`, not a
283    /// fully-anchored match — anchor explicitly with `^...$` if required).
284    ///
285    /// On failure the error `message` is `"does not match pattern
286    /// /{regex_src}/"`. If `regex_src` itself fails to compile, the failure
287    /// is surfaced as a [`ValidationError`] with `constraint = "pattern"`
288    /// and a message beginning with `"invalid regex pattern: "`. We
289    /// deliberately do not panic: the regex source comes from a
290    /// `#[validate(pattern = "...")]` attribute supplied by user code, and a
291    /// panic at validation time would be a poor failure mode for an HTTP
292    /// server.
293    pub fn pattern(path: &str, s: &str, regex_src: &str) -> Result<(), ValidationError> {
294        let re = match regex::Regex::new(regex_src) {
295            Ok(re) => re,
296            Err(e) => {
297                return Err(ValidationError::new(
298                    path,
299                    "pattern",
300                    format!("invalid regex pattern: {e}"),
301                ));
302            }
303        };
304        if re.is_match(s) {
305            Ok(())
306        } else {
307            // The offending input is deliberately omitted: the regex itself
308            // is the more informative bit, and quoting a long string risks
309            // blowing past the 80-char target.
310            Err(ValidationError::new(
311                path,
312                "pattern",
313                format!("does not match pattern /{regex_src}/"),
314            ))
315        }
316    }
317}
318
319/// Run a single check, pushing any error into `out` instead of bubbling it.
320///
321/// Used by `#[derive(Validate)]`-emitted code so that successive checks all run
322/// and accumulate errors rather than short-circuiting on the first failure.
323///
324/// # Example (mirrors what the derive emits)
325///
326/// ```
327/// use taut_rpc::validate::{self, check};
328///
329/// let mut errors = Vec::new();
330/// validate::collect(&mut errors, || check::min("x", 0.5, 1.0));
331/// validate::collect(&mut errors, || check::max("x", 0.5, 1.0));
332/// // First check failed, second passed: one error collected.
333/// assert_eq!(errors.len(), 1);
334/// assert_eq!(errors[0].constraint, "min");
335/// ```
336pub fn collect<F>(out: &mut Vec<ValidationError>, f: F)
337where
338    F: FnOnce() -> Result<(), ValidationError>,
339{
340    if let Err(e) = f() {
341        out.push(e);
342    }
343}
344
345/// Run a closure that accumulates errors into a fresh `Vec`, folding it into a
346/// `Result<(), Vec<ValidationError>>`.
347///
348/// This is the entry point the `#[derive(Validate)]` macro lowers each
349/// generated `Validate::validate` body to. Compare:
350///
351/// ```ignore
352/// // Generated impl:
353/// fn validate(&self) -> Result<(), Vec<ValidationError>> {
354///     taut_rpc::validate::run(|errors| {
355///         taut_rpc::validate::collect(errors, || check::min("age", self.age, 0.0));
356///         taut_rpc::validate::collect(errors, || check::max("age", self.age, 150.0));
357///         taut_rpc::validate::nested(errors, "address", &self.address);
358///     })
359/// }
360/// ```
361///
362/// Returns `Ok(())` if the closure pushed nothing, otherwise `Err(errors)`.
363pub fn run<F>(checks: F) -> Result<(), Vec<ValidationError>>
364where
365    F: FnOnce(&mut Vec<ValidationError>),
366{
367    let mut errors = Vec::new();
368    checks(&mut errors);
369    if errors.is_empty() {
370        Ok(())
371    } else {
372        Err(errors)
373    }
374}
375
376/// Run a nested type's [`Validate`] impl and re-prefix any errors so their
377/// `path` is rooted at `path_prefix`.
378///
379/// If the inner error has an empty `path`, the outer path becomes
380/// `path_prefix`; otherwise it becomes `<path_prefix>.<inner.path>`. This
381/// matches the dotted-path convention used by the derive when walking nested
382/// fields.
383///
384/// # Example
385///
386/// ```
387/// use taut_rpc::validate::{self, Validate, ValidationError, check};
388///
389/// struct Address { city: String }
390/// impl Validate for Address {
391///     fn validate(&self) -> Result<(), Vec<ValidationError>> {
392///         validate::run(|errors| {
393///             validate::collect(errors, || check::length("city", &self.city, Some(1), None));
394///         })
395///     }
396/// }
397///
398/// struct User { address: Address }
399/// impl Validate for User {
400///     fn validate(&self) -> Result<(), Vec<ValidationError>> {
401///         validate::run(|errors| {
402///             validate::nested(errors, "address", &self.address);
403///         })
404///     }
405/// }
406///
407/// let u = User { address: Address { city: String::new() } };
408/// let errs = u.validate().expect_err("city is empty");
409/// assert_eq!(errs[0].path, "address.city");
410/// ```
411pub fn nested<V>(out: &mut Vec<ValidationError>, path_prefix: &str, value: &V)
412where
413    V: Validate + ?Sized,
414{
415    if let Err(inner) = value.validate() {
416        for mut e in inner {
417            e.path = if e.path.is_empty() {
418                path_prefix.to_string()
419            } else {
420                format!("{path_prefix}.{}", e.path)
421            };
422            out.push(e);
423        }
424    }
425}
426
427// --- Blanket `Validate` impls for primitives and standard containers ---------
428//
429// Phase 4 (and Agent 6's `#[rpc]` macro) emit unconditional
430// `<I as Validate>::validate(&input)` calls after deserialization. For input
431// types like `u32`, `String`, or `(u32, String)`, that won't compile unless the
432// types implement `Validate`. The impls below are trivial pass-throughs:
433// primitives, `&'static str`, and the unit type `()` always validate; nested
434// containers (`Option`, `Vec`, `Box`, `HashMap`, tuples) recurse so that
435// user-defined types embedded inside them still get checked.
436//
437// Real per-field constraints live on user-defined types via
438// `#[derive(Validate)]`.
439
440/// No-op `Validate` impls for types whose values cannot themselves carry
441/// constraints — primitives, `&'static str`, and `()`.
442macro_rules! noop_validate {
443    ($($t:ty),* $(,)?) => {
444        $(
445            impl Validate for $t {
446                fn validate(&self) -> Result<(), Vec<ValidationError>> { Ok(()) }
447            }
448        )*
449    };
450}
451
452noop_validate!(
453    bool,
454    u8,
455    u16,
456    u32,
457    u64,
458    u128,
459    usize,
460    i8,
461    i16,
462    i32,
463    i64,
464    i128,
465    isize,
466    f32,
467    f64,
468    char,
469    String,
470    &'static str,
471    (),
472);
473
474impl<T: Validate> Validate for Option<T> {
475    fn validate(&self) -> Result<(), Vec<ValidationError>> {
476        match self {
477            Some(v) => v.validate(),
478            None => Ok(()),
479        }
480    }
481}
482
483impl<T: Validate> Validate for Vec<T> {
484    fn validate(&self) -> Result<(), Vec<ValidationError>> {
485        let mut errors = Vec::new();
486        for (i, v) in self.iter().enumerate() {
487            if let Err(mut errs) = v.validate() {
488                for e in &mut errs {
489                    e.path = if e.path.is_empty() {
490                        format!("[{i}]")
491                    } else {
492                        format!("[{i}].{}", e.path)
493                    };
494                }
495                errors.append(&mut errs);
496            }
497        }
498        if errors.is_empty() {
499            Ok(())
500        } else {
501            Err(errors)
502        }
503    }
504}
505
506impl<T: Validate> Validate for Box<T> {
507    fn validate(&self) -> Result<(), Vec<ValidationError>> {
508        (**self).validate()
509    }
510}
511
512impl<K, V: Validate, S: std::hash::BuildHasher> Validate for std::collections::HashMap<K, V, S> {
513    fn validate(&self) -> Result<(), Vec<ValidationError>> {
514        let mut errors = Vec::new();
515        for v in self.values() {
516            if let Err(mut errs) = v.validate() {
517                errors.append(&mut errs);
518            }
519        }
520        if errors.is_empty() {
521            Ok(())
522        } else {
523            Err(errors)
524        }
525    }
526}
527
528/// Tuple `Validate` impls (arity 1..=4). Each arm runs every element's
529/// `validate()` and accumulates failures rather than short-circuiting.
530macro_rules! tuple_validate {
531    ($($name:ident),+) => {
532        impl<$($name: Validate),+> Validate for ($($name,)+) {
533            #[allow(non_snake_case)]
534            fn validate(&self) -> Result<(), Vec<ValidationError>> {
535                let ($($name,)+) = self;
536                let mut errors = Vec::new();
537                $(
538                    if let Err(mut errs) = $name.validate() { errors.append(&mut errs); }
539                )+
540                if errors.is_empty() { Ok(()) } else { Err(errors) }
541            }
542        }
543    };
544}
545tuple_validate!(A);
546tuple_validate!(A, B);
547tuple_validate!(A, B, C);
548tuple_validate!(A, B, C, D);
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    // --- check::min / check::max ---------------------------------------------
555
556    #[test]
557    fn check_min_below_fails() {
558        let err = check::min("x", 0.5_f64, 1.0).expect_err("0.5 < 1.0 should fail");
559        assert_eq!(err.path, "x");
560        assert_eq!(err.constraint, "min");
561    }
562
563    #[test]
564    fn check_min_at_boundary_ok() {
565        check::min("x", 1.0_f64, 1.0).expect("value == min should pass");
566    }
567
568    #[test]
569    fn check_min_above_ok() {
570        check::min("x", 2.0_f64, 1.0).expect("value > min should pass");
571    }
572
573    #[test]
574    fn check_max_above_fails() {
575        let err = check::max("x", 1.5_f64, 1.0).expect_err("1.5 > 1.0 should fail");
576        assert_eq!(err.path, "x");
577        assert_eq!(err.constraint, "max");
578    }
579
580    #[test]
581    fn check_max_at_boundary_ok() {
582        check::max("x", 1.0_f64, 1.0).expect("value == max should pass");
583    }
584
585    #[test]
586    fn check_max_below_ok() {
587        check::max("x", 0.5_f64, 1.0).expect("value < max should pass");
588    }
589
590    // --- broadened numeric inputs --------------------------------------------
591
592    #[test]
593    fn check_min_accepts_i32() {
594        let v: i32 = -3;
595        let err = check::min("age", v, 0.0).expect_err("-3 < 0 should fail");
596        assert_eq!(err.constraint, "min");
597        check::min("age", 0_i32, 0.0).expect("0 == 0 passes");
598        check::min("age", 5_i32, 0.0).expect("5 > 0 passes");
599    }
600
601    #[test]
602    fn check_min_accepts_u64() {
603        // u64 doesn't impl Into<f64> — only u32 / u16 / u8 do losslessly.
604        // Use u32 here as the "wide unsigned" representative; pick u8 for the
605        // narrow path.
606        let v: u32 = 0;
607        let err = check::min("count", v, 1.0).expect_err("0 < 1 should fail");
608        assert_eq!(err.constraint, "min");
609        check::min("count", 1_u32, 1.0).expect("1 == 1 passes");
610        check::min("count", 100_u8, 1.0).expect("u8 100 > 1 passes");
611    }
612
613    #[test]
614    fn check_max_accepts_i32() {
615        let v: i32 = 200;
616        let err = check::max("age", v, 150.0).expect_err("200 > 150 should fail");
617        assert_eq!(err.constraint, "max");
618        check::max("age", 150_i32, 150.0).expect("150 == 150 passes");
619        check::max("age", -3_i32, 150.0).expect("-3 < 150 passes");
620    }
621
622    #[test]
623    fn check_max_accepts_u32() {
624        let v: u32 = 1000;
625        let err = check::max("count", v, 500.0).expect_err("1000 > 500 should fail");
626        assert_eq!(err.constraint, "max");
627        check::max("count", 0_u32, 500.0).expect("0 < 500 passes");
628        check::max("count", 5_u8, 10.0).expect("u8 5 < 10 passes");
629    }
630
631    #[test]
632    fn check_min_max_message_includes_value() {
633        let err = check::min("x", -2_i32, 0.0).expect_err("-2 < 0");
634        assert!(err.message.contains("-2"), "got {}", err.message);
635        let err = check::max("x", 999_u32, 10.0).expect_err("999 > 10");
636        assert!(err.message.contains("999"), "got {}", err.message);
637    }
638
639    // --- normalized message text --------------------------------------------
640    //
641    // SPEC §7 / Phase 4: every check::* message must be sentence-case (no
642    // leading capital, no trailing period), include the offending value,
643    // omit the field path, and stay under 80 chars. UIs render `path:
644    // message` themselves, so duplicating the path inside `message` would
645    // double up.
646
647    /// Helper: shared invariants every `check::*` message must satisfy.
648    fn assert_message_shape(message: &str) {
649        assert!(message.len() <= 80, "message > 80 chars: {message:?}");
650        assert!(
651            !message.ends_with('.'),
652            "message ends with a period: {message:?}"
653        );
654        let first = message.chars().next().expect("non-empty message");
655        assert!(
656            !first.is_uppercase(),
657            "message starts with uppercase: {message:?}"
658        );
659    }
660
661    #[test]
662    fn check_min_message_exact_text() {
663        let err = check::min("x", -2_i32, 0.0).expect_err("-2 < 0");
664        assert_eq!(err.message, "value -2 is less than minimum 0");
665        assert_message_shape(&err.message);
666        // Path must NOT appear inside message — UIs prepend it themselves.
667        assert!(
668            !err.message.contains("x:"),
669            "path leaked into message: {}",
670            err.message
671        );
672    }
673
674    #[test]
675    fn check_max_message_exact_text() {
676        let err = check::max("y", 5_i32, 1.0).expect_err("5 > 1");
677        assert_eq!(err.message, "value 5 is greater than maximum 1");
678        assert_message_shape(&err.message);
679    }
680
681    #[test]
682    fn check_length_message_both_bounds() {
683        let err =
684            check::length("name", "hello!", Some(2), Some(5)).expect_err("len 6 outside [2, 5]");
685        assert_eq!(err.message, "length 6 is outside [2, 5]");
686        assert_message_shape(&err.message);
687    }
688
689    #[test]
690    fn check_length_message_no_min() {
691        let err = check::length("name", "hello!", None, Some(5)).expect_err("len 6 outside [-, 5]");
692        assert_eq!(err.message, "length 6 is outside [no minimum, 5]");
693        assert_message_shape(&err.message);
694    }
695
696    #[test]
697    fn check_length_message_no_max() {
698        let err = check::length("name", "", Some(1), None).expect_err("len 0 outside [1, -]");
699        assert_eq!(err.message, "length 0 is outside [1, no maximum]");
700        assert_message_shape(&err.message);
701    }
702
703    #[test]
704    fn check_email_message_exact_text() {
705        let err = check::email("e", "nope").expect_err("not an email");
706        assert_eq!(err.message, "not a valid email: nope");
707        assert_message_shape(&err.message);
708    }
709
710    #[test]
711    fn check_email_message_truncates_long_input() {
712        // 30-char input — must be truncated to 20 chars + '…'.
713        let long = "x".repeat(30) + "@nope";
714        let err = check::email("e", &long).expect_err("malformed");
715        let head: String = "x".repeat(20);
716        assert_eq!(err.message, format!("not a valid email: {head}…"));
717        assert_message_shape(&err.message);
718    }
719
720    #[test]
721    fn check_url_message_exact_text() {
722        let err = check::url("u", "ftp://x").expect_err("ftp not allowed");
723        assert_eq!(err.message, "not a valid url: ftp://x");
724        assert_message_shape(&err.message);
725    }
726
727    #[test]
728    fn check_url_message_truncates_long_input() {
729        let long = "g".repeat(40);
730        let err = check::url("u", &long).expect_err("not a url");
731        let head: String = "g".repeat(20);
732        assert_eq!(err.message, format!("not a valid url: {head}…"));
733        assert_message_shape(&err.message);
734    }
735
736    #[test]
737    fn check_pattern_message_exact_text() {
738        let err = check::pattern("x", "abc", r"^\d+$").expect_err("no digits");
739        assert_eq!(err.message, r"does not match pattern /^\d+$/");
740        assert_message_shape(&err.message);
741        // Path must not be embedded.
742        assert!(!err.message.contains("x:"), "got {}", err.message);
743    }
744
745    // --- check::length -------------------------------------------------------
746
747    #[test]
748    fn check_length_max_only_ok() {
749        check::length("name", "hi", None, Some(5)).expect("len 2 <= 5");
750    }
751
752    #[test]
753    fn check_length_max_only_fails() {
754        let err =
755            check::length("name", "hello!", None, Some(5)).expect_err("len 6 > 5 should fail");
756        assert_eq!(err.constraint, "length");
757        assert_eq!(err.path, "name");
758    }
759
760    #[test]
761    fn check_length_min_and_max_ok() {
762        check::length("name", "hey", Some(2), Some(5)).expect("2 <= 3 <= 5");
763    }
764
765    #[test]
766    fn check_length_below_min_fails() {
767        let err = check::length("name", "x", Some(2), Some(5)).expect_err("len 1 < 2 should fail");
768        assert_eq!(err.constraint, "length");
769    }
770
771    #[test]
772    fn check_length_empty_with_min_fails() {
773        let err = check::length("name", "", Some(1), None).expect_err("empty string fails min(1)");
774        assert_eq!(err.constraint, "length");
775    }
776
777    #[test]
778    fn check_length_empty_no_min_ok() {
779        check::length("name", "", None, Some(5)).expect("empty allowed when no min");
780    }
781
782    #[test]
783    fn check_length_empty_no_bounds_ok() {
784        check::length("name", "", None, None).expect("no bounds always passes");
785    }
786
787    #[test]
788    fn check_length_counts_chars_not_bytes() {
789        // "é" is 2 bytes UTF-8 but 1 char.
790        check::length("name", "é", Some(1), Some(1)).expect("counts chars, not bytes");
791    }
792
793    // --- check::email --------------------------------------------------------
794
795    #[test]
796    fn check_email_accepts_simple() {
797        check::email("e", "a@b.co").expect("a@b.co is valid");
798    }
799
800    #[test]
801    fn check_email_rejects_no_dot() {
802        check::email("e", "a@b").expect_err("a@b has no dot after @");
803    }
804
805    #[test]
806    fn check_email_rejects_empty() {
807        check::email("e", "").expect_err("empty string is not an email");
808    }
809
810    #[test]
811    fn check_email_rejects_no_domain() {
812        check::email("e", "a.b@").expect_err("a.b@ has nothing after @");
813    }
814
815    #[test]
816    fn check_email_rejects_leading_at() {
817        check::email("e", "@b.co").expect_err("nothing before @");
818    }
819
820    #[test]
821    fn check_email_error_carries_constraint() {
822        let err = check::email("user.email", "nope").expect_err("nope is not an email");
823        assert_eq!(err.path, "user.email");
824        assert_eq!(err.constraint, "email");
825    }
826
827    // --- check::url ----------------------------------------------------------
828
829    #[test]
830    fn check_url_accepts_https() {
831        check::url("u", "https://x").expect("https://x is allowed");
832    }
833
834    #[test]
835    fn check_url_accepts_http() {
836        check::url("u", "http://x").expect("http://x is allowed");
837    }
838
839    #[test]
840    fn check_url_rejects_ftp() {
841        let err = check::url("u", "ftp://x").expect_err("ftp scheme not allowed");
842        assert_eq!(err.constraint, "url");
843        assert_eq!(err.path, "u");
844    }
845
846    #[test]
847    fn check_url_rejects_empty() {
848        check::url("u", "").expect_err("empty is not a URL");
849    }
850
851    // --- check::pattern ------------------------------------------------------
852
853    #[test]
854    fn check_pattern_matches() {
855        check::pattern("x", "abc123", r"\d+").expect("contains digits");
856    }
857
858    #[test]
859    fn check_pattern_anchored_full_match() {
860        check::pattern("x", "12345", r"^\d+$").expect("all digits");
861    }
862
863    #[test]
864    fn check_pattern_does_not_match() {
865        let err = check::pattern("x", "abc", r"^\d+$").expect_err("no digits");
866        assert_eq!(err.constraint, "pattern");
867        assert_eq!(err.path, "x");
868        assert!(err.message.contains(r"^\d+$"), "got {}", err.message);
869    }
870
871    #[test]
872    fn check_pattern_invalid_regex_returns_error_not_panic() {
873        // Unbalanced bracket: regex compile fails.
874        let err = check::pattern("x", "abc", r"[unclosed")
875            .expect_err("invalid regex source must surface as ValidationError");
876        assert_eq!(err.constraint, "pattern");
877        assert_eq!(err.path, "x");
878        assert!(
879            err.message.starts_with("invalid regex pattern:"),
880            "got {}",
881            err.message
882        );
883    }
884
885    #[test]
886    fn check_pattern_empty_input_against_optional_pattern() {
887        // The pattern `^$` matches the empty string only.
888        check::pattern("x", "", r"^$").expect("empty matches ^$");
889        check::pattern("x", "x", r"^$").expect_err("non-empty does not match ^$");
890    }
891
892    // --- collect -------------------------------------------------------------
893
894    #[test]
895    fn collect_pushes_on_err() {
896        let mut errors = Vec::new();
897        collect(&mut errors, || check::min("x", 0_i32, 1.0));
898        assert_eq!(errors.len(), 1);
899        assert_eq!(errors[0].constraint, "min");
900        assert_eq!(errors[0].path, "x");
901    }
902
903    #[test]
904    fn collect_skips_on_ok() {
905        let mut errors = Vec::new();
906        collect(&mut errors, || check::min("x", 5_i32, 1.0));
907        assert!(errors.is_empty());
908    }
909
910    #[test]
911    fn collect_accumulates_multiple_failures() {
912        let mut errors = Vec::new();
913        collect(&mut errors, || check::min("x", 0_i32, 1.0));
914        collect(&mut errors, || check::max("x", 100_i32, 10.0));
915        collect(&mut errors, || check::min("y", 5_i32, 1.0)); // ok
916        collect(&mut errors, || check::email("e", "nope"));
917        assert_eq!(errors.len(), 3);
918        assert_eq!(errors[0].constraint, "min");
919        assert_eq!(errors[1].constraint, "max");
920        assert_eq!(errors[2].constraint, "email");
921    }
922
923    // --- run -----------------------------------------------------------------
924
925    #[test]
926    fn run_returns_ok_when_no_errors_pushed() {
927        let result = run(|_errors| {});
928        assert!(result.is_ok());
929    }
930
931    #[test]
932    fn run_returns_ok_when_all_checks_pass() {
933        let result = run(|errors| {
934            collect(errors, || check::min("x", 5_i32, 1.0));
935            collect(errors, || check::max("x", 5_i32, 10.0));
936        });
937        assert!(result.is_ok());
938    }
939
940    #[test]
941    fn run_returns_err_with_all_collected_failures() {
942        let result = run(|errors| {
943            collect(errors, || check::min("x", 0_i32, 1.0));
944            collect(errors, || check::max("y", 100_i32, 10.0));
945        });
946        let errs = result.expect_err("two failures should make Err");
947        assert_eq!(errs.len(), 2);
948        assert_eq!(errs[0].path, "x");
949        assert_eq!(errs[0].constraint, "min");
950        assert_eq!(errs[1].path, "y");
951        assert_eq!(errs[1].constraint, "max");
952    }
953
954    #[test]
955    fn run_does_not_short_circuit() {
956        // Confirm every check inside `run`'s closure executes, even after an
957        // earlier one fails.
958        let mut counter = 0;
959        let result = run(|errors| {
960            counter += 1;
961            collect(errors, || check::min("x", 0_i32, 1.0));
962            counter += 1;
963            collect(errors, || check::max("x", 100_i32, 10.0));
964            counter += 1;
965        });
966        assert_eq!(counter, 3);
967        assert_eq!(result.expect_err("two failures").len(), 2);
968    }
969
970    // --- nested --------------------------------------------------------------
971
972    struct Inner {
973        a: i32,
974    }
975    impl Validate for Inner {
976        fn validate(&self) -> Result<(), Vec<ValidationError>> {
977            run(|errors| {
978                collect(errors, || check::min("a", self.a, 0.0));
979            })
980        }
981    }
982
983    struct Outer {
984        inner: Inner,
985    }
986    impl Validate for Outer {
987        fn validate(&self) -> Result<(), Vec<ValidationError>> {
988            run(|errors| {
989                nested(errors, "inner", &self.inner);
990            })
991        }
992    }
993
994    #[test]
995    fn nested_prefixes_inner_path() {
996        let outer = Outer {
997            inner: Inner { a: -1 },
998        };
999        let errs = outer.validate().expect_err("a < 0 should fail");
1000        assert_eq!(errs.len(), 1);
1001        assert_eq!(errs[0].path, "inner.a");
1002        assert_eq!(errs[0].constraint, "min");
1003    }
1004
1005    #[test]
1006    fn nested_passes_through_when_inner_ok() {
1007        let outer = Outer {
1008            inner: Inner { a: 5 },
1009        };
1010        outer.validate().expect("inner is valid");
1011    }
1012
1013    struct RootError;
1014    impl Validate for RootError {
1015        fn validate(&self) -> Result<(), Vec<ValidationError>> {
1016            Err(vec![ValidationError::new("", "custom", "root-level fail")])
1017        }
1018    }
1019
1020    #[test]
1021    fn nested_uses_prefix_alone_when_inner_path_empty() {
1022        let mut errors = Vec::new();
1023        nested(&mut errors, "field", &RootError);
1024        assert_eq!(errors.len(), 1);
1025        assert_eq!(errors[0].path, "field");
1026        assert_eq!(errors[0].constraint, "custom");
1027    }
1028
1029    #[test]
1030    fn nested_collects_multiple_inner_errors() {
1031        struct Multi;
1032        impl Validate for Multi {
1033            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1034                run(|errors| {
1035                    collect(errors, || check::min("a", 0_i32, 1.0));
1036                    collect(errors, || check::max("b", 100_i32, 10.0));
1037                })
1038            }
1039        }
1040        let mut errors = Vec::new();
1041        nested(&mut errors, "wrap", &Multi);
1042        assert_eq!(errors.len(), 2);
1043        assert_eq!(errors[0].path, "wrap.a");
1044        assert_eq!(errors[1].path, "wrap.b");
1045    }
1046
1047    #[test]
1048    fn nested_pushes_nothing_when_inner_ok() {
1049        struct AlwaysOk;
1050        impl Validate for AlwaysOk {
1051            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1052                Ok(())
1053            }
1054        }
1055        let mut errors = Vec::new();
1056        nested(&mut errors, "x", &AlwaysOk);
1057        assert!(errors.is_empty());
1058    }
1059
1060    #[test]
1061    fn nested_double_nesting_dotted_path() {
1062        // Outer wraps Outer wraps Inner: path should become "a.b.a".
1063        struct Deeper {
1064            outer: Outer,
1065        }
1066        impl Validate for Deeper {
1067            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1068                run(|errors| {
1069                    nested(errors, "outer", &self.outer);
1070                })
1071            }
1072        }
1073        let d = Deeper {
1074            outer: Outer {
1075                inner: Inner { a: -1 },
1076            },
1077        };
1078        let errs = d.validate().expect_err("inner a < 0");
1079        assert_eq!(errs.len(), 1);
1080        assert_eq!(errs[0].path, "outer.inner.a");
1081    }
1082
1083    // --- Constraint serde roundtrip -----------------------------------------
1084
1085    fn roundtrip(c: &Constraint) -> Constraint {
1086        let json = serde_json::to_string(c).expect("serialize Constraint");
1087        serde_json::from_str(&json).expect("deserialize Constraint")
1088    }
1089
1090    #[test]
1091    fn constraint_min_roundtrip() {
1092        let c = Constraint::Min(1.5);
1093        assert_eq!(roundtrip(&c), c);
1094        let json = serde_json::to_string(&c).expect("serialize");
1095        assert!(json.contains("\"kind\":\"min\""), "got {json}");
1096    }
1097
1098    #[test]
1099    fn constraint_max_roundtrip() {
1100        let c = Constraint::Max(10.0);
1101        assert_eq!(roundtrip(&c), c);
1102        let json = serde_json::to_string(&c).expect("serialize");
1103        assert!(json.contains("\"kind\":\"max\""), "got {json}");
1104    }
1105
1106    #[test]
1107    fn constraint_length_roundtrip() {
1108        let c = Constraint::Length {
1109            min: Some(1),
1110            max: Some(64),
1111        };
1112        assert_eq!(roundtrip(&c), c);
1113        let json = serde_json::to_string(&c).expect("serialize");
1114        assert!(json.contains("\"kind\":\"length\""), "got {json}");
1115    }
1116
1117    #[test]
1118    fn constraint_length_max_only_roundtrip() {
1119        let c = Constraint::Length {
1120            min: None,
1121            max: Some(64),
1122        };
1123        assert_eq!(roundtrip(&c), c);
1124    }
1125
1126    #[test]
1127    fn constraint_pattern_roundtrip() {
1128        let c = Constraint::Pattern(r"^\d+$".to_string());
1129        assert_eq!(roundtrip(&c), c);
1130        let json = serde_json::to_string(&c).expect("serialize");
1131        assert!(json.contains("\"kind\":\"pattern\""), "got {json}");
1132    }
1133
1134    #[test]
1135    fn constraint_email_roundtrip() {
1136        let c = Constraint::Email;
1137        assert_eq!(roundtrip(&c), c);
1138        let json = serde_json::to_string(&c).expect("serialize");
1139        assert!(json.contains("\"kind\":\"email\""), "got {json}");
1140    }
1141
1142    #[test]
1143    fn constraint_url_roundtrip() {
1144        let c = Constraint::Url;
1145        assert_eq!(roundtrip(&c), c);
1146        let json = serde_json::to_string(&c).expect("serialize");
1147        assert!(json.contains("\"kind\":\"url\""), "got {json}");
1148    }
1149
1150    #[test]
1151    fn constraint_custom_roundtrip() {
1152        let c = Constraint::Custom("must_be_prime".to_string());
1153        assert_eq!(roundtrip(&c), c);
1154        let json = serde_json::to_string(&c).expect("serialize");
1155        assert!(json.contains("\"kind\":\"custom\""), "got {json}");
1156    }
1157
1158    // --- ValidationError surface --------------------------------------------
1159
1160    #[test]
1161    fn validation_error_display_uses_path_and_message() {
1162        let err = ValidationError::new("user.email", "email", "bad");
1163        assert_eq!(format!("{err}"), "user.email: bad");
1164    }
1165
1166    #[test]
1167    fn validation_error_serde_roundtrip() {
1168        let err = ValidationError::new("a.b", "min", "too small");
1169        let json = serde_json::to_string(&err).expect("serialize");
1170        let back: ValidationError = serde_json::from_str(&json).expect("deserialize");
1171        assert_eq!(back, err);
1172    }
1173
1174    // --- Blanket Validate impls (primitives / Option / Vec / tuples) --------
1175
1176    #[test]
1177    fn validate_for_unit_returns_ok() {
1178        ().validate().expect("unit always validates");
1179    }
1180
1181    #[test]
1182    fn validate_for_primitives_all_return_ok() {
1183        true.validate().expect("bool always ok");
1184        42_u32.validate().expect("u32 always ok");
1185        "hello".to_string().validate().expect("String always ok");
1186    }
1187
1188    #[test]
1189    fn validate_for_option_t_calls_inner_when_some() {
1190        // Inner type that fails validation unless its value is >= 0.
1191        struct Field(i32);
1192        impl Validate for Field {
1193            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1194                run(|errors| {
1195                    collect(errors, || check::min("v", self.0, 0.0));
1196                })
1197            }
1198        }
1199
1200        // None: skips inner — passes.
1201        let none: Option<Field> = None;
1202        none.validate().expect("None passes");
1203
1204        // Some(ok): inner ok, outer ok.
1205        Some(Field(5))
1206            .validate()
1207            .expect("Some with valid inner passes");
1208
1209        // Some(bad): inner fails, outer surfaces the failure.
1210        let errs = Some(Field(-1))
1211            .validate()
1212            .expect_err("Some with -1 should fail inner check");
1213        assert_eq!(errs.len(), 1);
1214        assert_eq!(errs[0].constraint, "min");
1215        assert_eq!(errs[0].path, "v");
1216    }
1217
1218    #[test]
1219    fn validate_for_vec_indexes_path() {
1220        // Inner type that fails when value < 0, with path "v".
1221        struct Field(i32);
1222        impl Validate for Field {
1223            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1224                run(|errors| {
1225                    collect(errors, || check::min("v", self.0, 0.0));
1226                })
1227            }
1228        }
1229
1230        // Inner type whose error has an empty path — used below to verify
1231        // that index-only paths don't grow a trailing dot.
1232        struct RootFail;
1233        impl Validate for RootFail {
1234            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1235                Err(vec![ValidationError::new("", "custom", "boom")])
1236            }
1237        }
1238
1239        let v = vec![Field(5), Field(-1), Field(10), Field(-2)];
1240        let errs = v.validate().expect_err("indices 1 and 3 should fail");
1241        assert_eq!(errs.len(), 2);
1242        assert_eq!(errs[0].path, "[1].v");
1243        assert_eq!(errs[0].constraint, "min");
1244        assert_eq!(errs[1].path, "[3].v");
1245        assert_eq!(errs[1].constraint, "min");
1246
1247        // Empty Vec passes.
1248        let empty: Vec<Field> = Vec::new();
1249        empty.validate().expect("empty Vec passes");
1250
1251        // All-ok Vec passes.
1252        let ok = vec![Field(0), Field(1), Field(2)];
1253        ok.validate().expect("all-valid Vec passes");
1254
1255        // Inner with empty path: index alone, no trailing dot.
1256        let v = vec![RootFail, RootFail];
1257        let errs = v.validate().expect_err("both fail at root");
1258        assert_eq!(errs.len(), 2);
1259        assert_eq!(errs[0].path, "[0]");
1260        assert_eq!(errs[1].path, "[1]");
1261    }
1262
1263    #[test]
1264    fn validate_for_tuple_runs_all_arms() {
1265        // Inner that fails iff its value is negative, recording path "v".
1266        struct Field(i32);
1267        impl Validate for Field {
1268            fn validate(&self) -> Result<(), Vec<ValidationError>> {
1269                run(|errors| {
1270                    collect(errors, || check::min("v", self.0, 0.0));
1271                })
1272            }
1273        }
1274
1275        // (A,) — single arm.
1276        let one = (Field(-1),);
1277        let errs = one.validate().expect_err("single-arm tuple fails");
1278        assert_eq!(errs.len(), 1);
1279
1280        // (A, B) — both arms run, both fail.
1281        let two = (Field(-1), Field(-2));
1282        let errs = two.validate().expect_err("both arms fail");
1283        assert_eq!(errs.len(), 2, "tuple must not short-circuit");
1284
1285        // (A, B, C) — mixed.
1286        let three = (Field(-1), Field(0), Field(-3));
1287        let errs = three.validate().expect_err("two of three fail");
1288        assert_eq!(errs.len(), 2);
1289
1290        // (A, B, C, D) — all ok.
1291        let four = (Field(0), Field(1), Field(2), Field(3));
1292        four.validate().expect("all-valid 4-tuple passes");
1293
1294        // Mixed primitives + user types compile and run.
1295        let mixed: (u32, String, Field) = (1, "x".into(), Field(5));
1296        mixed.validate().expect("primitives + ok user type pass");
1297    }
1298}