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