Skip to main content

star_toml/
validation.rs

1//! Pydantic-grade + Van der Aalst-grade validation for TOML configs.
2//!
3//! # Design
4//!
5//! Validation works by *descent*: implement [`Validate`] for each config type,
6//! use a [`Validator`] to record failures, and compose nested types with
7//! [`Validator::field`] / [`Validator::index`]. Every `check_*` call is an
8//! atomic "check event" — the validator counts all checks (pass + fail) for the
9//! conformance score.
10//!
11//! # Validator method reference
12//!
13//! ## Descent (path tracking)
14//!
15//! | Method | Signature | Effect |
16//! |--------|-----------|--------|
17//! | [`field`](Validator::field) | `(name: &str, f: FnOnce(&mut Validator))` | Push key segment, run `f`, pop |
18//! | [`index`](Validator::index) | `(i: usize, f: FnOnce(&mut Validator))` | Push index segment, run `f`, pop |
19//!
20//! ## Built-in checks (each counts as one check event)
21//!
22//! | Method | Code | Fails when |
23//! |--------|------|-----------|
24//! | [`check_non_empty`](Validator::check_non_empty) | `empty` | `&str` is `""` |
25//! | [`check_range`](Validator::check_range) | `out_of_range` | value outside `lo..=hi` |
26//! | [`check_one_of`](Validator::check_one_of) | `not_one_of` | value not in allowed slice |
27//! | [`check_predicate`](Validator::check_predicate) | caller-defined | boolean is `false` |
28//! | [`check_consistent`](Validator::check_consistent) | caller-defined | cross-field condition is `false` |
29//!
30//! ## Severity control
31//!
32//! | Method | Effect |
33//! |--------|--------|
34//! | [`with_severity`](Validator::with_severity) | Sets [`Severity`] for all checks inside the closure |
35//!
36//! Default severity: [`Severity::Error`].
37//! Errors with `Severity < Error` (Warning / Advisory) still appear in the report
38//! but do not block [`ValidationErrors::has_fatal`].
39//!
40//! ## Raw error recording
41//!
42//! | Method | Description |
43//! |--------|-------------|
44//! | [`error`](Validator::error) | Record [`ErrorKind`] at the current location |
45//! | [`error_with`](Validator::error_with) | Same, capturing the offending value as a string |
46//! | [`finish`](Validator::finish) | Consume the validator → `Ok(())` or `Err(ValidationErrors)` |
47//!
48//! # ValidationErrors analytics
49//!
50//! | Method | Returns | Van der Aalst concept |
51//! |--------|---------|----------------------|
52//! | [`errors`](ValidationErrors::errors) | `&[ValidationError]` | — |
53//! | [`fitness`](ValidationErrors::fitness) | `f64` 0.0–1.0 | Replay fitness / alignment score |
54//! | [`variant_id`](ValidationErrors::variant_id) | `u64` | Trace variant fingerprint |
55//! | [`by_section`](ValidationErrors::by_section) | `BTreeMap<String, Vec<_>>` | OCEL object-centric view |
56//! | [`has_fatal`](ValidationErrors::has_fatal) | `bool` | Halt-immediately signal |
57//! | [`errors_above`](ValidationErrors::errors_above) | `impl Iterator` | Severity filter |
58//!
59//! # ValidationError fields
60//!
61//! | Field | Type | Description |
62//! |-------|------|-------------|
63//! | `loc` | [`Loc`] | Path, e.g. `server.tls.port` or `[2].name` |
64//! | `kind` | [`ErrorKind`] | Structured reason (machine-matchable) |
65//! | `severity` | [`Severity`] | Advisory / Warning / Error / Fatal |
66//! | `input` | `Option<String>` | Offending value, if captured |
67//! | `msg` | `String` | Human-readable message |
68//!
69//! Plus: [`code()`](ValidationError::code), [`repair_hint()`](ValidationError::repair_hint),
70//! [`is_fatal()`](ValidationError::is_fatal).
71//!
72//! # ErrorKind codes
73//!
74//! | Variant | `code()` | Produced by |
75//! |---------|----------|-------------|
76//! | `Missing` | `missing` | `error(ErrorKind::Missing, …)` |
77//! | `Empty` | `empty` | `check_non_empty` |
78//! | `OutOfRange` | `out_of_range` | `check_range` |
79//! | `TooShort` | `too_short` | `error(ErrorKind::TooShort{…}, …)` |
80//! | `TooLong` | `too_long` | `error(ErrorKind::TooLong{…}, …)` |
81//! | `NotOneOf` | `not_one_of` | `check_one_of` |
82//! | `Inconsistent` | caller-defined | `check_consistent` |
83//! | `Predicate` | caller-defined | `check_predicate`, `error(ErrorKind::Predicate{…}, …)` |
84//!
85//! ```
86//! use star_toml::{Validate, Validator, Severity};
87//!
88//! struct Server { host: String, port: u16 }
89//!
90//! impl Validate for Server {
91//!     fn validate(&self, v: &mut Validator) {
92//!         v.check_non_empty("host", &self.host);
93//!         v.check_range("port", self.port, 1..=65535);
94//!     }
95//! }
96//!
97//! let bad = Server { host: String::new(), port: 0 };
98//! let errs = bad.check().unwrap_err();
99//! assert_eq!(errs.len(), 2);
100//! assert_eq!(errs.fitness(), 0.0);               // 0 of 2 checks passed
101//! assert!(!errs.errors()[0].repair_hint().is_empty());
102//! ```
103
104use std::collections::BTreeMap;
105use std::fmt;
106use std::ops::RangeInclusive;
107
108// ---------------------------------------------------------------------------
109// Location — a path into the config tree
110// ---------------------------------------------------------------------------
111
112/// One segment of a [`Loc`]: either a table key or an array index.
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114pub enum LocSegment {
115    /// A table key, e.g. `server` in `server.port`.
116    Key(String),
117    /// An array index, e.g. `2` in `stages[2]`.
118    Index(usize),
119}
120
121/// A path to a value in the config tree, rendered like `server.tls.port` or `stages[2].name`.
122#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
123pub struct Loc(pub(crate) Vec<LocSegment>);
124
125impl Loc {
126    /// The segments making up this location, outermost first.
127    #[must_use]
128    pub fn segments(&self) -> &[LocSegment] {
129        &self.0
130    }
131
132    /// True for a root-level (whole-model) location with no segments.
133    #[must_use]
134    pub fn is_root(&self) -> bool {
135        self.0.is_empty()
136    }
137}
138
139impl fmt::Display for Loc {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        if self.0.is_empty() {
142            return write!(f, "(root)");
143        }
144        for (i, seg) in self.0.iter().enumerate() {
145            match seg {
146                LocSegment::Key(k) => {
147                    if i > 0 {
148                        write!(f, ".")?;
149                    }
150                    write!(f, "{k}")?;
151                }
152                LocSegment::Index(n) => write!(f, "[{n}]")?,
153            }
154        }
155        Ok(())
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Severity — Van der Aalst alignment cost levels
161// ---------------------------------------------------------------------------
162
163/// How severe a validation failure is — ordered least to most severe.
164///
165/// The default for all `check_*` methods is [`Error`](Severity::Error).
166/// Use [`Validator::with_severity`] to override for a closure.
167///
168/// Comparison: `Advisory < Warning < Error < Fatal`.
169///
170/// | Level | Meaning | `has_fatal` |
171/// |-------|---------|-------------|
172/// | `Advisory` | best-practice hint; config is usable | no |
173/// | `Warning` | risky but technically valid | no |
174/// | `Error` | constraint violated; config is broken | no |
175/// | `Fatal` | unrecoverable; halt all evaluation | **yes** |
176///
177/// # Example
178///
179/// ```
180/// use star_toml::{Validate, Validator, Severity};
181///
182/// struct Cfg { log_dir: String }
183/// impl Validate for Cfg {
184///     fn validate(&self, v: &mut Validator) {
185///         v.with_severity(Severity::Warning, |v| {
186///             v.check_non_empty("log_dir", &self.log_dir);
187///         });
188///     }
189/// }
190/// let errs = Cfg { log_dir: String::new() }.check().unwrap_err();
191/// assert_eq!(errs.errors()[0].severity, Severity::Warning);
192/// assert!(!errs.has_fatal());
193/// ```
194#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
195pub enum Severity {
196    /// Informational: not a hard rule, but a best-practice recommendation.
197    Advisory,
198    /// Technically acceptable but risky or sub-optimal.
199    Warning,
200    /// Constraint violated — the default level. Config is unusable.
201    #[default]
202    Error,
203    /// Unrecoverable: halt immediately, do not evaluate further constraints.
204    Fatal,
205}
206
207impl Severity {
208    /// Stable string code for this severity level.
209    #[must_use]
210    pub fn code(&self) -> &str {
211        match self {
212            Self::Advisory => "advisory",
213            Self::Warning => "warning",
214            Self::Error => "error",
215            Self::Fatal => "fatal",
216        }
217    }
218}
219
220impl fmt::Display for Severity {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        f.write_str(self.code())
223    }
224}
225
226// ---------------------------------------------------------------------------
227// ErrorKind — structured, machine-matchable
228// ---------------------------------------------------------------------------
229
230/// The structured reason a value failed validation.
231///
232/// Each variant maps to a stable [`code`](ErrorKind::code) string (Pydantic's "type"),
233/// suitable for programmatic matching, while carrying the specifics inline.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum ErrorKind {
236    /// A required value was absent.
237    Missing,
238    /// A string/collection was empty but must not be.
239    Empty,
240    /// A number fell outside the allowed range.
241    OutOfRange {
242        /// Inclusive lower bound, if any.
243        lower: Option<String>,
244        /// Inclusive upper bound, if any.
245        upper: Option<String>,
246    },
247    /// A string/collection was shorter than allowed.
248    TooShort {
249        /// Minimum length.
250        min: usize,
251        /// Actual length.
252        actual: usize,
253    },
254    /// A string/collection was longer than allowed.
255    TooLong {
256        /// Maximum length.
257        max: usize,
258        /// Actual length.
259        actual: usize,
260    },
261    /// A value was not among the permitted choices.
262    NotOneOf {
263        /// The permitted values.
264        allowed: Vec<String>,
265    },
266    /// A cross-field DECLARE constraint was violated.
267    ///
268    /// `related` names the other field(s) involved in the constraint.
269    Inconsistent {
270        /// The other field names that form this cross-field constraint.
271        related: Vec<String>,
272        /// Caller-defined stable code.
273        code: &'static str,
274    },
275    /// A custom predicate failed; `code` is a caller-chosen stable identifier.
276    Predicate {
277        /// Stable, caller-defined error code.
278        code: &'static str,
279    },
280}
281
282impl ErrorKind {
283    /// A stable, machine-matchable code for this error kind (Pydantic's error "type").
284    #[must_use]
285    pub fn code(&self) -> &str {
286        match self {
287            Self::Missing => "missing",
288            Self::Empty => "empty",
289            Self::OutOfRange { .. } => "out_of_range",
290            Self::TooShort { .. } => "too_short",
291            Self::TooLong { .. } => "too_long",
292            Self::NotOneOf { .. } => "not_one_of",
293            Self::Inconsistent { code, .. } | Self::Predicate { code } => code,
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// ValidationError — one failure
300// ---------------------------------------------------------------------------
301
302/// A single validation failure at a precise [`Loc`].
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct ValidationError {
305    /// Where in the config tree the failure occurred.
306    pub loc: Loc,
307    /// The structured reason.
308    pub kind: ErrorKind,
309    /// How severe this failure is.
310    pub severity: Severity,
311    /// The offending value, stringified, if it was captured.
312    pub input: Option<String>,
313    /// Human-readable message.
314    pub msg: String,
315}
316
317impl ValidationError {
318    /// Shorthand for `self.kind.code()`.
319    #[must_use]
320    pub fn code(&self) -> &str {
321        self.kind.code()
322    }
323
324    /// Whether this error requires an immediate halt (severity == Fatal).
325    #[must_use]
326    pub fn is_fatal(&self) -> bool {
327        self.severity == Severity::Fatal
328    }
329
330    /// Auto-derived repair suggestion based on the error kind.
331    ///
332    /// For custom predicates the message itself is the best hint; for
333    /// built-in kinds the hint is derived from the constraint parameters.
334    ///
335    /// This implements Van der Aalst's *alignment repair* concept: given a
336    /// deviation from the reference model, what is the minimum edit?
337    #[must_use]
338    pub fn repair_hint(&self) -> String {
339        match &self.kind {
340            ErrorKind::Empty => "provide a non-empty value".into(),
341            ErrorKind::Missing => "add this required field".into(),
342            ErrorKind::OutOfRange { lower, upper } => match (lower, upper) {
343                (Some(lo), Some(hi)) => format!("use a value in the range {lo}..={hi}"),
344                (Some(lo), None) => format!("use a value ≥ {lo}"),
345                (None, Some(hi)) => format!("use a value ≤ {hi}"),
346                (None, None) => "use a value within the required range".into(),
347            },
348            ErrorKind::NotOneOf { allowed } => {
349                format!("choose one of: {}", allowed.join(", "))
350            }
351            ErrorKind::TooShort { min, .. } => format!("provide at least {min} items/characters"),
352            ErrorKind::TooLong { max, .. } => format!("use at most {max} items/characters"),
353            ErrorKind::Inconsistent { related, .. } => {
354                format!(
355                    "ensure this field is consistent with: {}",
356                    related.join(", ")
357                )
358            }
359            ErrorKind::Predicate { .. } => self.msg.clone(),
360        }
361    }
362}
363
364impl fmt::Display for ValidationError {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        write!(f, "{}\n  {}", self.loc, self.msg)?;
367        if let Some(input) = &self.input {
368            write!(f, " (got: `{input}`)")?;
369        }
370        write!(f, " [{}]", self.code())?;
371        if self.severity != Severity::Error {
372            write!(f, " <{}>", self.severity)?;
373        }
374        Ok(())
375    }
376}
377
378// ---------------------------------------------------------------------------
379// ValidationErrors — the collected report
380// ---------------------------------------------------------------------------
381
382/// A non-empty collection of [`ValidationError`]s, rendered as a Pydantic-style report.
383///
384/// Extends the Pydantic report with:
385/// - [`fitness`](ValidationErrors::fitness) — Van der Aalst alignment conformance score
386/// - [`variant_id`](ValidationErrors::variant_id) — fingerprint for recurring failure patterns
387/// - [`by_section`](ValidationErrors::by_section) — object-centric grouping
388///
389/// ```text
390/// 2 validation errors for Server
391/// host
392///   must not be empty (got: `""`) [empty]
393/// port
394///   input must be in range 1..=65535 (got: `0`) [out_of_range]
395/// ```
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct ValidationErrors {
398    pub(crate) errors: Vec<ValidationError>,
399    pub(crate) title: Option<String>,
400    /// Total number of checks attempted (passed + failed). Used for fitness.
401    pub(crate) checks_run: usize,
402}
403
404impl ValidationErrors {
405    /// The individual errors, in the order they were discovered (depth-first).
406    #[must_use]
407    pub fn errors(&self) -> &[ValidationError] {
408        &self.errors
409    }
410
411    /// Number of collected errors (always ≥ 1).
412    #[must_use]
413    pub fn len(&self) -> usize {
414        self.errors.len()
415    }
416
417    /// Always `false` — `ValidationErrors` only exists when there is ≥ 1 error.
418    #[must_use]
419    pub fn is_empty(&self) -> bool {
420        self.errors.is_empty()
421    }
422
423    /// The model/type name this report is about, if set.
424    #[must_use]
425    pub fn title(&self) -> Option<&str> {
426        self.title.as_deref()
427    }
428
429    /// Set the report title to the short name of `T` (e.g. `ServerConfig`).
430    pub fn set_title_for<T: ?Sized>(&mut self) {
431        let full = std::any::type_name::<T>();
432        let short = full.rsplit("::").next().unwrap_or(full);
433        self.title = Some(short.to_string());
434    }
435
436    /// Whether any error is [`Severity::Fatal`] (requires immediate halt).
437    #[must_use]
438    pub fn has_fatal(&self) -> bool {
439        self.errors.iter().any(ValidationError::is_fatal)
440    }
441
442    /// Errors at or above the given severity threshold.
443    pub fn errors_above(&self, min: Severity) -> impl Iterator<Item = &ValidationError> {
444        self.errors.iter().filter(move |e| e.severity >= min)
445    }
446
447    /// **Van der Aalst alignment fitness** — proportion of checks that passed.
448    ///
449    /// Returns 1.0 when all checks pass (no errors), 0.0 when every check
450    /// failed. Analogous to the replay-fitness metric from conformance checking:
451    /// how well does the observed config align to the declared validation model?
452    ///
453    /// # Example
454    ///
455    /// ```
456    /// use star_toml::{Validate, Validator};
457    ///
458    /// struct Pair { a: u32, b: u32 }
459    /// impl Validate for Pair {
460    ///     fn validate(&self, v: &mut Validator) {
461    ///         v.check_range("a", self.a, 1..=10);  // passes
462    ///         v.check_range("b", self.b, 1..=10);  // fails
463    ///     }
464    /// }
465    /// let errs = Pair { a: 5, b: 0 }.check().unwrap_err();
466    /// assert_eq!(errs.fitness(), 0.5);  // 1 of 2 checks passed
467    /// ```
468    #[must_use]
469    pub fn fitness(&self) -> f64 {
470        if self.checks_run == 0 {
471            return 1.0;
472        }
473        let failed = self
474            .errors
475            .iter()
476            .filter(|e| e.severity >= Severity::Error)
477            .count();
478        let passed = self.checks_run.saturating_sub(failed);
479        passed as f64 / self.checks_run as f64
480    }
481
482    /// **Variant fingerprint** — a deterministic hash of the failure pattern.
483    ///
484    /// Two `ValidationErrors` instances with the same set of `(location, code)`
485    /// pairs produce the same variant ID, regardless of message text or input
486    /// values. Useful for deduplicating recurring failure patterns across runs.
487    ///
488    /// Uses FNV-1a over the sorted `"loc:code"` pairs.
489    #[must_use]
490    pub fn variant_id(&self) -> u64 {
491        let mut pairs: Vec<String> = self
492            .errors
493            .iter()
494            .map(|e| format!("{}:{}", e.loc, e.code()))
495            .collect();
496        pairs.sort_unstable();
497        fnv1a(pairs.join("|").as_bytes())
498    }
499
500    /// **Object-centric grouping** — errors indexed by their top-level config section.
501    ///
502    /// Implements Van der Aalst's object-centric view: each top-level TOML table
503    /// is an "object type"; this groups all its constraint violations together.
504    ///
505    /// Root-level errors are keyed `"(root)"`.
506    #[must_use]
507    pub fn by_section(&self) -> BTreeMap<String, Vec<&ValidationError>> {
508        let mut map: BTreeMap<String, Vec<&ValidationError>> = BTreeMap::new();
509        for err in &self.errors {
510            let key = err
511                .loc
512                .segments()
513                .first()
514                .and_then(|s| {
515                    if let LocSegment::Key(k) = s {
516                        Some(k.as_str())
517                    } else {
518                        None
519                    }
520                })
521                .unwrap_or("(root)");
522            map.entry(key.to_string()).or_default().push(err);
523        }
524        map
525    }
526}
527
528impl fmt::Display for ValidationErrors {
529    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530        let n = self.errors.len();
531        let noun = if n == 1 { "error" } else { "errors" };
532        match &self.title {
533            Some(t) => writeln!(f, "{n} validation {noun} for {t}")?,
534            None => writeln!(f, "{n} validation {noun}")?,
535        }
536        for (i, err) in self.errors.iter().enumerate() {
537            if i > 0 {
538                writeln!(f)?;
539            }
540            write!(f, "{err}")?;
541        }
542        Ok(())
543    }
544}
545
546impl std::error::Error for ValidationErrors {}
547
548// ---------------------------------------------------------------------------
549// Validator — the descent context
550// ---------------------------------------------------------------------------
551
552/// Accumulates errors while tracking the current location as you descend a config tree.
553///
554/// Obtain one via [`Validate::check`] / [`Validate::validated`], or construct directly
555/// with [`Validator::new`] for ad-hoc validation. Use [`field`](Validator::field) and
556/// [`index`](Validator::index) to descend; the `check_*` helpers record errors at the
557/// named sub-location with the offending value attached.
558///
559/// Use [`with_severity`](Validator::with_severity) to emit [`Warning`](Severity::Warning)
560/// or [`Fatal`](Severity::Fatal) errors. Use
561/// [`check_consistent`](Validator::check_consistent) for cross-field DECLARE constraints.
562#[derive(Debug, Default)]
563pub struct Validator {
564    loc: Vec<LocSegment>,
565    errors: Vec<ValidationError>,
566    /// Total atomic checks performed (pass or fail), used to compute fitness.
567    checks_run: usize,
568    /// Severity to stamp on the next emitted error (reset after each `record`).
569    pending_severity: Severity,
570}
571
572impl Validator {
573    /// A fresh validator positioned at the root.
574    #[must_use]
575    pub fn new() -> Self {
576        Self::default()
577    }
578
579    /// Descend into table key `name`, run `f`, then pop back out.
580    ///
581    /// Any errors recorded inside `f` are prefixed with `name`.
582    pub fn field(&mut self, name: &str, f: impl FnOnce(&mut Validator)) {
583        self.loc.push(LocSegment::Key(name.to_string()));
584        f(self);
585        self.loc.pop();
586    }
587
588    /// Descend into array index `i`, run `f`, then pop back out.
589    pub fn index(&mut self, i: usize, f: impl FnOnce(&mut Validator)) {
590        self.loc.push(LocSegment::Index(i));
591        f(self);
592        self.loc.pop();
593    }
594
595    /// Run `f` with the given severity applied to every error emitted inside it.
596    ///
597    /// Severity resets to the enclosing scope's value after `f` returns.
598    ///
599    /// ```
600    /// use star_toml::{Validate, Validator, Severity};
601    ///
602    /// struct Config { log_dir: String }
603    /// impl Validate for Config {
604    ///     fn validate(&self, v: &mut Validator) {
605    ///         // Best-practice advisory: non-critical
606    ///         v.with_severity(Severity::Warning, |v| {
607    ///             v.check_non_empty("log_dir", &self.log_dir);
608    ///         });
609    ///     }
610    /// }
611    /// let errs = Config { log_dir: String::new() }.check().unwrap_err();
612    /// assert_eq!(errs.errors()[0].severity, Severity::Warning);
613    /// assert!(!errs.has_fatal());
614    /// ```
615    pub fn with_severity(&mut self, severity: Severity, f: impl FnOnce(&mut Validator)) {
616        let prev = std::mem::replace(&mut self.pending_severity, severity);
617        f(self);
618        self.pending_severity = prev;
619    }
620
621    /// Record an error at the current location.
622    pub fn error(&mut self, kind: ErrorKind, msg: impl Into<String>) {
623        self.record(kind, None, msg.into());
624    }
625
626    /// Record an error at the current location, capturing an offending value.
627    pub fn error_with(
628        &mut self, kind: ErrorKind, input: impl fmt::Display, msg: impl Into<String>,
629    ) {
630        self.record(kind, Some(input.to_string()), msg.into());
631    }
632
633    /// Fail subfield `field` with [`ErrorKind::Empty`] if `value` is empty.
634    pub fn check_non_empty(&mut self, field: &str, value: &str) {
635        self.checks_run += 1;
636        if value.is_empty() {
637            self.at(field, |v| {
638                v.error_with(ErrorKind::Empty, "\"\"", "must not be empty");
639            });
640        }
641    }
642
643    /// Fail subfield `field` with [`ErrorKind::OutOfRange`] if `value ∉ range`.
644    pub fn check_range<T>(&mut self, field: &str, value: T, range: RangeInclusive<T>)
645    where
646        T: PartialOrd + fmt::Display + Copy,
647    {
648        self.checks_run += 1;
649        if !range.contains(&value) {
650            let (lo, hi) = (range.start().to_string(), range.end().to_string());
651            let msg = format!("input must be in range {lo}..={hi}");
652            self.at(field, |v| {
653                v.error_with(
654                    ErrorKind::OutOfRange {
655                        lower: Some(lo),
656                        upper: Some(hi),
657                    },
658                    value,
659                    msg,
660                );
661            });
662        }
663    }
664
665    /// Fail subfield `field` with [`ErrorKind::NotOneOf`] if `value` is not in `allowed`.
666    pub fn check_one_of(&mut self, field: &str, value: &str, allowed: &[&str]) {
667        self.checks_run += 1;
668        if !allowed.contains(&value) {
669            let allowed_owned: Vec<String> = allowed.iter().map(|s| (*s).to_string()).collect();
670            let msg = format!("must be one of: {}", allowed.join(", "));
671            self.at(field, |v| {
672                v.error_with(
673                    ErrorKind::NotOneOf {
674                        allowed: allowed_owned,
675                    },
676                    value,
677                    msg,
678                );
679            });
680        }
681    }
682
683    /// Fail subfield `field` with a caller-defined `code` when `passed` is false.
684    ///
685    /// The escape hatch for arbitrary domain rules.
686    pub fn check_predicate(
687        &mut self, field: &str, passed: bool, code: &'static str, msg: impl Into<String>,
688    ) {
689        self.checks_run += 1;
690        if !passed {
691            let msg = msg.into();
692            self.at(field, |v| v.error(ErrorKind::Predicate { code }, msg));
693        }
694    }
695
696    /// **DECLARE-style cross-field constraint** (Van der Aalst).
697    ///
698    /// Records an [`ErrorKind::Inconsistent`] at `primary_field` when `condition`
699    /// is `false`, tagging `related_fields` as the other objects in the constraint.
700    ///
701    /// This models DECLARE's *co-existence*, *response*, and *precedence*
702    /// templates: field A is only valid in relation to field B.
703    ///
704    /// ```
705    /// use star_toml::{Validate, Validator};
706    ///
707    /// struct Tls { enabled: bool, cert_path: String }
708    /// impl Validate for Tls {
709    ///     fn validate(&self, v: &mut Validator) {
710    ///         // Co-existence: TLS enabled ⟺ cert_path non-empty
711    ///         v.check_consistent(
712    ///             "cert_path",
713    ///             &["enabled"],
714    ///             !self.enabled || !self.cert_path.is_empty(),
715    ///             "tls_cert_required",
716    ///             "cert_path must be set when TLS is enabled",
717    ///         );
718    ///     }
719    /// }
720    /// let bad = Tls { enabled: true, cert_path: String::new() };
721    /// let errs = bad.check().unwrap_err();
722    /// assert_eq!(errs.errors()[0].code(), "tls_cert_required");
723    /// ```
724    pub fn check_consistent(
725        &mut self, primary_field: &str, related_fields: &[&str], condition: bool,
726        code: &'static str, msg: impl Into<String>,
727    ) {
728        self.checks_run += 1;
729        if !condition {
730            let related: Vec<String> = related_fields.iter().map(|s| (*s).to_string()).collect();
731            let msg = msg.into();
732            self.at(primary_field, |v| {
733                v.error(ErrorKind::Inconsistent { related, code }, msg);
734            });
735        }
736    }
737
738    /// Fail subfield `field` if `value` is not a valid semver string (e.g. "1.0.0").
739    pub fn check_semver(&mut self, field: &str, value: &str) {
740        self.checks_run += 1;
741        let parts: Vec<&str> = value.split('.').collect();
742        let is_valid = parts.len() == 3
743            && parts.iter().all(|p| {
744                !p.is_empty()
745                    && p.chars().all(|c| c.is_ascii_digit())
746                    && !(p.len() > 1 && p.starts_with('0'))
747                    && p.parse::<u32>().is_ok()
748            });
749        if !is_valid {
750            let msg = format!(
751                "Invalid version format: '{}'. Expected semver format (e.g., 1.0.0)",
752                value
753            );
754            self.at(field, |v| {
755                v.error_with(
756                    ErrorKind::Predicate {
757                        code: "invalid_semver",
758                    },
759                    value,
760                    msg,
761                );
762            });
763        }
764    }
765
766    /// Fail subfield `field` if `value` is not a valid IP or domain hostname.
767    pub fn check_ip_or_domain(&mut self, field: &str, value: &str) {
768        self.checks_run += 1;
769        let is_ip = value.parse::<std::net::IpAddr>().is_ok();
770        let is_hostname = if value.is_empty() || value.len() > 253 {
771            false
772        } else {
773            let normalized = value.strip_suffix('.').unwrap_or(value);
774            if normalized.is_empty() {
775                false
776            } else {
777                normalized.split('.').all(|label| {
778                    !label.is_empty()
779                        && label.len() <= 63
780                        && !label.starts_with('-')
781                        && !label.ends_with('-')
782                        && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
783                })
784            }
785        };
786        if !is_ip && !is_hostname {
787            let msg = format!("Invalid IP or domain hostname: '{}'", value);
788            self.at(field, |v| {
789                v.error_with(
790                    ErrorKind::Predicate {
791                        code: "invalid_ip_or_domain",
792                    },
793                    value,
794                    msg,
795                );
796            });
797        }
798    }
799
800    /// Fail subfield `field` if `value` is not a safe path (e.g. non-empty, no traversal, no null bytes, and optionally absolute/relative).
801    pub fn check_path(&mut self, field: &str, value: &str, must_be_absolute: Option<bool>) {
802        self.checks_run += 1;
803
804        if value.is_empty() {
805            self.at(field, |v| {
806                v.error_with(ErrorKind::Empty, "\"\"", "path must not be empty");
807            });
808            return;
809        }
810
811        let mut errors = Vec::new();
812
813        if value.contains('\0') {
814            errors.push("path must not contain null bytes".to_string());
815        }
816
817        let path = std::path::Path::new(value);
818        let has_traversal = path
819            .components()
820            .any(|c| c == std::path::Component::ParentDir)
821            || value.split(['/', '\\']).any(|s| s == "..");
822        if has_traversal {
823            errors.push("path traversal ('..') is not allowed".to_string());
824        }
825
826        if let Some(absolute) = must_be_absolute {
827            if absolute && !path.is_absolute() {
828                errors.push("path must be absolute".to_string());
829            } else if !absolute && !path.is_relative() {
830                errors.push("path must be relative".to_string());
831            }
832        }
833
834        if !errors.is_empty() {
835            let msg = format!("Invalid path '{}': {}", value, errors.join(", "));
836            self.at(field, |v| {
837                v.error_with(
838                    ErrorKind::Predicate {
839                        code: "invalid_path",
840                    },
841                    value,
842                    msg,
843                );
844            });
845        }
846    }
847
848    /// Fail subfield `field` if `value` does not conform to cache size formats (e.g. "512MB").
849    pub fn check_size_format(&mut self, field: &str, value: &str) {
850        self.checks_run += 1;
851        let val_upper = value.to_uppercase();
852        let suffixes = ["B", "KB", "MB", "GB", "TB"];
853        let mut is_valid = false;
854        for suffix in suffixes {
855            if let Some(prefix) = val_upper.strip_suffix(suffix) {
856                if !prefix.is_empty()
857                    && prefix.chars().all(|c| c.is_ascii_digit())
858                    && prefix.parse::<u64>().is_ok()
859                {
860                    is_valid = true;
861                    break;
862                }
863            }
864        }
865        if !is_valid {
866            let msg = format!(
867                "Invalid size format: '{}'. Expected format like '1GB', '512MB'",
868                value
869            );
870            self.at(field, |v| {
871                v.error_with(
872                    ErrorKind::Predicate {
873                        code: "invalid_size_format",
874                    },
875                    value,
876                    msg,
877                );
878            });
879        }
880    }
881
882    /// Consume the validator, yielding `Ok(())` if no errors were recorded.
883    ///
884    /// # Errors
885    ///
886    /// Returns [`ValidationErrors`] containing every recorded failure.
887    pub fn finish(self) -> Result<(), ValidationErrors> {
888        if self.errors.is_empty() {
889            Ok(())
890        } else {
891            Err(ValidationErrors {
892                errors: self.errors,
893                title: None,
894                checks_run: self.checks_run,
895            })
896        }
897    }
898
899    // -- internal ----------------------------------------------------------
900
901    fn at(&mut self, field: &str, f: impl FnOnce(&mut Validator)) {
902        self.field(field, f);
903    }
904
905    fn record(&mut self, kind: ErrorKind, input: Option<String>, msg: String) {
906        let severity = std::mem::take(&mut self.pending_severity);
907        self.errors.push(ValidationError {
908            loc: Loc(self.loc.clone()),
909            kind,
910            severity,
911            input,
912            msg,
913        });
914    }
915}
916
917// ---------------------------------------------------------------------------
918// Validate trait
919// ---------------------------------------------------------------------------
920
921/// Implemented by config types that can check their own invariants.
922///
923/// Implement [`validate`](Validate::validate) — record failures into the [`Validator`].
924/// The provided [`check`](Validate::check) and [`validated`](Validate::validated) methods
925/// run it and produce a titled [`ValidationErrors`] report.
926///
927/// Compose nested types with [`Validator::field`]:
928///
929/// ```
930/// use star_toml::{Validate, Validator};
931///
932/// struct Tls { cert_path: String }
933/// struct Server { port: u16, tls: Option<Tls> }
934///
935/// impl Validate for Tls {
936///     fn validate(&self, v: &mut Validator) {
937///         v.check_non_empty("cert_path", &self.cert_path);
938///     }
939/// }
940/// impl Validate for Server {
941///     fn validate(&self, v: &mut Validator) {
942///         v.check_range("port", self.port, 1..=65535);
943///         if let Some(tls) = &self.tls {
944///             v.field("tls", |v| tls.validate(v));   // nested errors → tls.cert_path
945///         }
946///     }
947/// }
948///
949/// let s = Server { port: 0, tls: Some(Tls { cert_path: String::new() }) };
950/// let errs = s.check().unwrap_err();
951/// let locs: Vec<String> = errs.errors().iter().map(|e| e.loc.to_string()).collect();
952/// assert_eq!(locs, ["port", "tls.cert_path"]);
953/// ```
954pub trait Validate {
955    /// Record any invariant violations into `v`.
956    fn validate(&self, v: &mut Validator);
957
958    /// Run validation, returning a titled error report on failure.
959    ///
960    /// # Errors
961    ///
962    /// Returns [`ValidationErrors`] if any invariant is violated.
963    fn check(&self) -> Result<(), ValidationErrors> {
964        let mut v = Validator::new();
965        self.validate(&mut v);
966        v.finish().map_err(|mut errs| {
967            errs.set_title_for::<Self>();
968            errs
969        })
970    }
971
972    /// Like [`check`](Validate::check) but consumes `self` and returns it on success —
973    /// handy for `let cfg = raw.validated()?;` pipelines.
974    ///
975    /// # Errors
976    ///
977    /// Returns [`ValidationErrors`] if any invariant is violated.
978    fn validated(self) -> Result<Self, ValidationErrors>
979    where
980        Self: Sized,
981    {
982        match self.check() {
983            Ok(()) => Ok(self),
984            Err(errs) => Err(errs),
985        }
986    }
987}
988
989// ---------------------------------------------------------------------------
990// FNV-1a — for variant fingerprinting (no external deps)
991// ---------------------------------------------------------------------------
992
993fn fnv1a(data: &[u8]) -> u64 {
994    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
995    const PRIME: u64 = 0x0000_0100_0000_01b3;
996    data.iter().fold(OFFSET, |hash, &byte| {
997        (hash ^ u64::from(byte)).wrapping_mul(PRIME)
998    })
999}
1000
1001// ---------------------------------------------------------------------------
1002// Tests
1003// ---------------------------------------------------------------------------
1004
1005#[cfg(test)]
1006#[allow(clippy::unwrap_used, clippy::panic)]
1007mod tests {
1008    use super::*;
1009
1010    struct Tls {
1011        cert_path: String,
1012        key_path: String,
1013    }
1014    struct Server {
1015        host: String,
1016        port: u16,
1017        tls: Option<Tls>,
1018    }
1019    struct App {
1020        name: String,
1021        workers: u32,
1022        log_level: String,
1023        server: Server,
1024    }
1025
1026    impl Validate for Tls {
1027        fn validate(&self, v: &mut Validator) {
1028            v.check_non_empty("cert_path", &self.cert_path);
1029            v.check_non_empty("key_path", &self.key_path);
1030        }
1031    }
1032    impl Validate for Server {
1033        fn validate(&self, v: &mut Validator) {
1034            v.check_non_empty("host", &self.host);
1035            v.check_range("port", self.port, 1..=65535);
1036            if let Some(tls) = &self.tls {
1037                v.field("tls", |v| tls.validate(v));
1038            }
1039        }
1040    }
1041    impl Validate for App {
1042        fn validate(&self, v: &mut Validator) {
1043            v.check_non_empty("name", &self.name);
1044            v.check_range("workers", self.workers, 1..=1024);
1045            v.check_one_of(
1046                "log_level",
1047                &self.log_level,
1048                &["trace", "debug", "info", "warn", "error"],
1049            );
1050            v.field("server", |v| self.server.validate(v));
1051        }
1052    }
1053
1054    fn valid_app() -> App {
1055        App {
1056            name: "demo".into(),
1057            workers: 8,
1058            log_level: "info".into(),
1059            server: Server {
1060                host: "localhost".into(),
1061                port: 8080,
1062                tls: None,
1063            },
1064        }
1065    }
1066
1067    // -- original Pydantic-grade tests (unchanged behaviour) ----------------
1068
1069    #[test]
1070    fn valid_config_passes() {
1071        assert!(valid_app().check().is_ok());
1072    }
1073
1074    #[test]
1075    fn collects_all_errors_not_just_first() {
1076        let app = App {
1077            name: String::new(),
1078            workers: 0,
1079            log_level: "verbose".into(),
1080            server: Server {
1081                host: String::new(),
1082                port: 0,
1083                tls: None,
1084            },
1085        };
1086        let errs = app.check().unwrap_err();
1087        assert_eq!(errs.len(), 5);
1088    }
1089
1090    #[test]
1091    fn locations_are_path_precise() {
1092        let app = App {
1093            server: Server {
1094                host: "ok".into(),
1095                port: 0,
1096                tls: Some(Tls {
1097                    cert_path: String::new(),
1098                    key_path: "key.pem".into(),
1099                }),
1100            },
1101            ..valid_app()
1102        };
1103        let errs = app.check().unwrap_err();
1104        let locs: Vec<String> = errs.errors().iter().map(|e| e.loc.to_string()).collect();
1105        assert!(locs.contains(&"server.port".to_string()));
1106        assert!(locs.contains(&"server.tls.cert_path".to_string()));
1107    }
1108
1109    #[test]
1110    fn error_codes_are_machine_matchable() {
1111        let app = App {
1112            log_level: "nope".into(),
1113            ..valid_app()
1114        };
1115        let errs = app.check().unwrap_err();
1116        assert_eq!(errs.errors()[0].code(), "not_one_of");
1117        match &errs.errors()[0].kind {
1118            ErrorKind::NotOneOf { allowed } => assert!(allowed.contains(&"info".to_string())),
1119            other => panic!("expected NotOneOf, got {other:?}"),
1120        }
1121    }
1122
1123    #[test]
1124    fn captured_input_value_present() {
1125        let app = App {
1126            workers: 9999,
1127            ..valid_app()
1128        };
1129        let errs = app.check().unwrap_err();
1130        assert_eq!(errs.errors()[0].input.as_deref(), Some("9999"));
1131    }
1132
1133    #[test]
1134    fn report_has_title_and_is_pretty() {
1135        let app = App {
1136            name: String::new(),
1137            ..valid_app()
1138        };
1139        let errs = app.check().unwrap_err();
1140        let report = errs.to_string();
1141        assert!(report.starts_with("1 validation error for App"));
1142        assert!(report.contains("name"));
1143        assert!(report.contains("[empty]"));
1144    }
1145
1146    #[test]
1147    fn index_segments_render_with_brackets() {
1148        struct Stages(Vec<String>);
1149        impl Validate for Stages {
1150            fn validate(&self, v: &mut Validator) {
1151                for (i, name) in self.0.iter().enumerate() {
1152                    v.index(i, |v| v.check_non_empty("name", name));
1153                }
1154            }
1155        }
1156        let stages = Stages(vec!["ok".into(), String::new()]);
1157        let errs = stages.check().unwrap_err();
1158        assert_eq!(errs.errors()[0].loc.to_string(), "[1].name");
1159    }
1160
1161    #[test]
1162    fn root_level_error_renders_as_root() {
1163        struct Thing;
1164        impl Validate for Thing {
1165            fn validate(&self, v: &mut Validator) {
1166                v.error(ErrorKind::Predicate { code: "always" }, "always fails");
1167            }
1168        }
1169        let errs = Thing.check().unwrap_err();
1170        assert_eq!(errs.errors()[0].loc.to_string(), "(root)");
1171        assert!(errs.errors()[0].loc.is_root());
1172    }
1173
1174    // -- Van der Aalst: severity stratification ----------------------------
1175
1176    #[test]
1177    fn default_severity_is_error() {
1178        let app = App {
1179            name: String::new(),
1180            ..valid_app()
1181        };
1182        let errs = app.check().unwrap_err();
1183        assert_eq!(errs.errors()[0].severity, Severity::Error);
1184    }
1185
1186    #[test]
1187    fn with_severity_stamps_warning() {
1188        struct Cfg {
1189            log_dir: String,
1190        }
1191        impl Validate for Cfg {
1192            fn validate(&self, v: &mut Validator) {
1193                v.with_severity(Severity::Warning, |v| {
1194                    v.check_non_empty("log_dir", &self.log_dir);
1195                });
1196            }
1197        }
1198        let errs = Cfg {
1199            log_dir: String::new(),
1200        }
1201        .check()
1202        .unwrap_err();
1203        assert_eq!(errs.errors()[0].severity, Severity::Warning);
1204        assert!(!errs.has_fatal());
1205    }
1206
1207    #[test]
1208    fn fatal_severity_detected() {
1209        struct Cfg;
1210        impl Validate for Cfg {
1211            fn validate(&self, v: &mut Validator) {
1212                v.with_severity(Severity::Fatal, |v| {
1213                    v.error(ErrorKind::Missing, "signing key is absent");
1214                });
1215            }
1216        }
1217        let errs = Cfg.check().unwrap_err();
1218        assert!(errs.has_fatal());
1219        assert!(errs.errors()[0].is_fatal());
1220    }
1221
1222    // -- Van der Aalst: conformance fitness --------------------------------
1223
1224    #[test]
1225    fn fitness_is_one_when_valid() {
1226        struct Good {
1227            x: u32,
1228        }
1229        impl Validate for Good {
1230            fn validate(&self, v: &mut Validator) {
1231                v.check_range("x", self.x, 1..=10);
1232            }
1233        }
1234        // valid → no errors, fitness should be accessible via a fresh validator
1235        // (only meaningful on error path, but the doc example has a passing case)
1236        assert!(Good { x: 5 }.check().is_ok());
1237    }
1238
1239    #[test]
1240    fn fitness_half_when_one_of_two_fails() {
1241        struct Pair {
1242            a: u32,
1243            b: u32,
1244        }
1245        impl Validate for Pair {
1246            fn validate(&self, v: &mut Validator) {
1247                v.check_range("a", self.a, 1..=10); // passes
1248                v.check_range("b", self.b, 1..=10); // fails
1249            }
1250        }
1251        let errs = Pair { a: 5, b: 0 }.check().unwrap_err();
1252        assert_eq!(errs.fitness(), 0.5);
1253    }
1254
1255    #[test]
1256    fn fitness_zero_when_all_fail() {
1257        let app = App {
1258            name: String::new(),
1259            workers: 0,
1260            log_level: "verbose".into(),
1261            server: Server {
1262                host: String::new(),
1263                port: 0,
1264                tls: None,
1265            },
1266        };
1267        let errs = app.check().unwrap_err();
1268        assert_eq!(errs.fitness(), 0.0);
1269    }
1270
1271    // -- Van der Aalst: repair hints ---------------------------------------
1272
1273    #[test]
1274    fn repair_hint_for_empty() {
1275        let app = App {
1276            name: String::new(),
1277            ..valid_app()
1278        };
1279        let errs = app.check().unwrap_err();
1280        assert_eq!(errs.errors()[0].repair_hint(), "provide a non-empty value");
1281    }
1282
1283    #[test]
1284    fn repair_hint_for_out_of_range() {
1285        let app = App {
1286            workers: 9999,
1287            ..valid_app()
1288        };
1289        let errs = app.check().unwrap_err();
1290        assert!(errs.errors()[0].repair_hint().contains("1..=1024"));
1291    }
1292
1293    #[test]
1294    fn repair_hint_for_not_one_of() {
1295        let app = App {
1296            log_level: "nope".into(),
1297            ..valid_app()
1298        };
1299        let errs = app.check().unwrap_err();
1300        let hint = errs.errors()[0].repair_hint();
1301        assert!(hint.contains("trace"));
1302        assert!(hint.contains("error"));
1303    }
1304
1305    // -- Van der Aalst: variant fingerprint --------------------------------
1306
1307    #[test]
1308    fn same_error_pattern_same_variant_id() {
1309        let app1 = App {
1310            name: String::new(),
1311            ..valid_app()
1312        };
1313        let app2 = App {
1314            name: String::new(),
1315            ..valid_app()
1316        };
1317        assert_eq!(
1318            app1.check().unwrap_err().variant_id(),
1319            app2.check().unwrap_err().variant_id()
1320        );
1321    }
1322
1323    #[test]
1324    fn different_error_pattern_different_variant_id() {
1325        let app1 = App {
1326            name: String::new(),
1327            ..valid_app()
1328        };
1329        let app2 = App {
1330            workers: 9999,
1331            ..valid_app()
1332        };
1333        assert_ne!(
1334            app1.check().unwrap_err().variant_id(),
1335            app2.check().unwrap_err().variant_id()
1336        );
1337    }
1338
1339    // -- Van der Aalst: object-centric grouping ----------------------------
1340
1341    #[test]
1342    fn by_section_groups_errors_by_top_level_key() {
1343        let app = App {
1344            name: String::new(),
1345            workers: 0,
1346            server: Server {
1347                host: String::new(),
1348                port: 0,
1349                tls: None,
1350            },
1351            ..valid_app()
1352        };
1353        let errs = app.check().unwrap_err();
1354        let by_sec = errs.by_section();
1355        assert!(by_sec.contains_key("name"));
1356        assert!(by_sec.contains_key("workers"));
1357        assert!(by_sec.contains_key("server"));
1358        // server.host + server.port are both under "server"
1359        assert_eq!(by_sec["server"].len(), 2);
1360    }
1361
1362    // -- Van der Aalst: DECLARE cross-field constraints --------------------
1363
1364    #[test]
1365    fn check_consistent_records_inconsistent_error() {
1366        struct Tls2 {
1367            enabled: bool,
1368            cert_path: String,
1369        }
1370        impl Validate for Tls2 {
1371            fn validate(&self, v: &mut Validator) {
1372                v.check_consistent(
1373                    "cert_path",
1374                    &["enabled"],
1375                    !self.enabled || !self.cert_path.is_empty(),
1376                    "tls_cert_required",
1377                    "cert_path must be set when TLS is enabled",
1378                );
1379            }
1380        }
1381        let bad = Tls2 {
1382            enabled: true,
1383            cert_path: String::new(),
1384        };
1385        let errs = bad.check().unwrap_err();
1386        assert_eq!(errs.errors()[0].code(), "tls_cert_required");
1387        assert_eq!(errs.errors()[0].loc.to_string(), "cert_path");
1388        match &errs.errors()[0].kind {
1389            ErrorKind::Inconsistent { related, .. } => {
1390                assert!(related.contains(&"enabled".to_string()));
1391            }
1392            other => panic!("expected Inconsistent, got {other:?}"),
1393        }
1394    }
1395
1396    #[test]
1397    fn check_consistent_passes_when_condition_true() {
1398        struct Tls2 {
1399            enabled: bool,
1400            cert_path: String,
1401        }
1402        impl Validate for Tls2 {
1403            fn validate(&self, v: &mut Validator) {
1404                v.check_consistent(
1405                    "cert_path",
1406                    &["enabled"],
1407                    !self.enabled || !self.cert_path.is_empty(),
1408                    "tls_cert_required",
1409                    "cert_path must be set when TLS is enabled",
1410                );
1411            }
1412        }
1413        assert!(Tls2 {
1414            enabled: true,
1415            cert_path: "/etc/cert.pem".into()
1416        }
1417        .check()
1418        .is_ok());
1419    }
1420
1421    #[test]
1422    fn test_check_semver() {
1423        struct Ver(String);
1424        impl Validate for Ver {
1425            fn validate(&self, v: &mut Validator) {
1426                v.check_semver("version", &self.0);
1427            }
1428        }
1429
1430        // Valid versions
1431        assert!(Ver("1.0.0".into()).check().is_ok());
1432        assert!(Ver("0.0.0".into()).check().is_ok());
1433        assert!(Ver("10.23.456".into()).check().is_ok());
1434
1435        // Invalid versions
1436        let test_cases = vec![
1437            ("", "invalid_semver"),
1438            ("1.0", "invalid_semver"),
1439            ("1.0.0.0", "invalid_semver"),
1440            ("a.b.c", "invalid_semver"),
1441            ("1.a.0", "invalid_semver"),
1442            ("1.0.0-alpha", "invalid_semver"),
1443            ("-1.0.0", "invalid_semver"),
1444            ("1.-0.0", "invalid_semver"),
1445            ("01.0.0", "invalid_semver"),
1446            ("1.01.0", "invalid_semver"),
1447            ("1.0.01", "invalid_semver"),
1448        ];
1449
1450        for (val, expected_code) in test_cases {
1451            let errs = Ver(val.to_string()).check().unwrap_err();
1452            assert_eq!(errs.len(), 1);
1453            assert_eq!(errs.errors()[0].code(), expected_code);
1454            assert_eq!(errs.errors()[0].input.as_deref(), Some(val));
1455            assert!(errs.errors()[0].msg.contains("Invalid version format"));
1456        }
1457    }
1458
1459    #[test]
1460    fn test_check_ip_or_domain() {
1461        struct Host(String);
1462        impl Validate for Host {
1463            fn validate(&self, v: &mut Validator) {
1464                v.check_ip_or_domain("host", &self.0);
1465            }
1466        }
1467
1468        // Valid IPs and hostnames
1469        assert!(Host("127.0.0.1".into()).check().is_ok());
1470        assert!(Host("::1".into()).check().is_ok());
1471        assert!(Host("localhost".into()).check().is_ok());
1472        assert!(Host("example.com".into()).check().is_ok());
1473        assert!(Host("example.com.".into()).check().is_ok()); // trailing dot allowed
1474        assert!(Host("sub-domain.example.co.uk".into()).check().is_ok());
1475        assert!(Host("123.abc.xyz".into()).check().is_ok());
1476
1477        // Invalid
1478        let test_cases = vec![
1479            ("".to_string(), "invalid_ip_or_domain"),
1480            ("a".repeat(254), "invalid_ip_or_domain"), // too long (>253)
1481            ("-example.com".to_string(), "invalid_ip_or_domain"), // leading hyphen
1482            ("example-.com".to_string(), "invalid_ip_or_domain"), // trailing hyphen in label
1483            ("example.com-".to_string(), "invalid_ip_or_domain"), // trailing hyphen in label
1484            ("a..b".to_string(), "invalid_ip_or_domain"), // empty label
1485            ("a.b_c.d".to_string(), "invalid_ip_or_domain"), // invalid character (underscore)
1486            (
1487                "label-".to_string() + &"a".repeat(60) + ".com",
1488                "invalid_ip_or_domain",
1489            ), // label too long (>63)
1490        ];
1491
1492        for (val, expected_code) in test_cases {
1493            let errs = Host(val.clone()).check().unwrap_err();
1494            assert_eq!(errs.len(), 1);
1495            assert_eq!(errs.errors()[0].code(), expected_code);
1496            assert_eq!(errs.errors()[0].input.as_deref(), Some(val.as_str()));
1497            assert!(errs.errors()[0]
1498                .msg
1499                .contains("Invalid IP or domain hostname"));
1500        }
1501    }
1502
1503    #[test]
1504    fn test_check_path() {
1505        struct PathVal {
1506            path: String,
1507            must_be_absolute: Option<bool>,
1508        }
1509        impl Validate for PathVal {
1510            fn validate(&self, v: &mut Validator) {
1511                v.check_path("path", &self.path, self.must_be_absolute);
1512            }
1513        }
1514
1515        // 1. Non-empty check
1516        let errs = PathVal {
1517            path: "".into(),
1518            must_be_absolute: None,
1519        }
1520        .check()
1521        .unwrap_err();
1522        assert_eq!(errs.len(), 1);
1523        assert_eq!(errs.errors()[0].code(), "empty");
1524        assert_eq!(errs.errors()[0].msg, "path must not be empty");
1525
1526        // 2. Null byte check
1527        let errs = PathVal {
1528            path: "foo\0bar".into(),
1529            must_be_absolute: None,
1530        }
1531        .check()
1532        .unwrap_err();
1533        assert_eq!(errs.len(), 1);
1534        assert_eq!(errs.errors()[0].code(), "invalid_path");
1535        assert!(errs.errors()[0]
1536            .msg
1537            .contains("path must not contain null bytes"));
1538
1539        // 3. Path traversal check
1540        let errs = PathVal {
1541            path: "foo/../bar".into(),
1542            must_be_absolute: None,
1543        }
1544        .check()
1545        .unwrap_err();
1546        assert_eq!(errs.len(), 1);
1547        assert_eq!(errs.errors()[0].code(), "invalid_path");
1548        assert!(errs.errors()[0]
1549            .msg
1550            .contains("path traversal ('..') is not allowed"));
1551
1552        // 4. Absolute check
1553        let errs = PathVal {
1554            path: "relative/path".into(),
1555            must_be_absolute: Some(true),
1556        }
1557        .check()
1558        .unwrap_err();
1559        assert_eq!(errs.len(), 1);
1560        assert_eq!(errs.errors()[0].code(), "invalid_path");
1561        assert!(errs.errors()[0].msg.contains("path must be absolute"));
1562
1563        // 5. Relative check
1564        let abs_path = std::env::current_dir()
1565            .unwrap()
1566            .to_string_lossy()
1567            .to_string();
1568        let errs = PathVal {
1569            path: abs_path.clone(),
1570            must_be_absolute: Some(false),
1571        }
1572        .check()
1573        .unwrap_err();
1574        assert_eq!(errs.len(), 1);
1575        assert_eq!(errs.errors()[0].code(), "invalid_path");
1576        assert!(errs.errors()[0].msg.contains("path must be relative"));
1577
1578        // 6. Valid combinations
1579        assert!(PathVal {
1580            path: "safe/path".into(),
1581            must_be_absolute: None
1582        }
1583        .check()
1584        .is_ok());
1585        assert!(PathVal {
1586            path: "safe/path".into(),
1587            must_be_absolute: Some(false)
1588        }
1589        .check()
1590        .is_ok());
1591        assert!(PathVal {
1592            path: abs_path.clone(),
1593            must_be_absolute: Some(true)
1594        }
1595        .check()
1596        .is_ok());
1597    }
1598
1599    #[test]
1600    fn test_check_size_format() {
1601        struct CacheSize(String);
1602        impl Validate for CacheSize {
1603            fn validate(&self, v: &mut Validator) {
1604                v.check_size_format("cache_size", &self.0);
1605            }
1606        }
1607
1608        // Valid sizes
1609        assert!(CacheSize("10B".into()).check().is_ok());
1610        assert!(CacheSize("512KB".into()).check().is_ok());
1611        assert!(CacheSize("1024MB".into()).check().is_ok());
1612        assert!(CacheSize("1GB".into()).check().is_ok());
1613        assert!(CacheSize("2TB".into()).check().is_ok());
1614        assert!(CacheSize("512mb".into()).check().is_ok()); // case-insensitive
1615
1616        // Invalid
1617        let test_cases = vec![
1618            ("".to_string(), "invalid_size_format"),
1619            ("10".to_string(), "invalid_size_format"), // missing suffix
1620            ("MB".to_string(), "invalid_size_format"), // missing number
1621            ("1.5GB".to_string(), "invalid_size_format"), // decimal not allowed
1622            ("512 MB".to_string(), "invalid_size_format"), // space not allowed
1623            ("10PB".to_string(), "invalid_size_format"), // invalid suffix PB
1624        ];
1625
1626        for (val, expected_code) in test_cases {
1627            let errs = CacheSize(val.clone()).check().unwrap_err();
1628            assert_eq!(errs.len(), 1);
1629            assert_eq!(errs.errors()[0].code(), expected_code);
1630            assert_eq!(errs.errors()[0].input.as_deref(), Some(val.as_str()));
1631            assert!(errs.errors()[0].msg.contains("Invalid size format"));
1632        }
1633    }
1634}