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}