Skip to main content

formars_core/
error.rs

1//! Accumulated, path-addressed validation errors.
2//!
3//! [`FormaError`] carries every violated constraint from one parse call as an
4//! ordered vector of [`FormaIssue`]s; each issue is addressed by a
5//! [`FieldPath`], tagged with a stable [`IssueCode`], and parameterized for
6//! deterministic display.
7//!
8//! [`FormaError`]: crate::error::FormaError
9//! [`FormaIssue`]: crate::error::FormaIssue
10//! [`FieldPath`]: crate::error::FieldPath
11//! [`IssueCode`]: crate::error::IssueCode
12
13use std::borrow::Cow;
14use std::fmt;
15
16/// Address of the field a [`FormaIssue`] refers to.
17///
18/// Top-level primitives report [`FieldPath::ROOT`]; object schemas join
19/// field segments via [`FieldPath::join`], so nested issues render as
20/// dotted paths (`user.email`).
21#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct FieldPath {
23    segments: Vec<Segment>,
24}
25
26/// One step in a [`FieldPath`]: either an object key or an array index.
27#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub enum Segment {
29    /// Object key segment (e.g. `user`).
30    Key(Box<str>),
31    /// Array index segment rendered as `[2]`.
32    Index(usize),
33}
34
35impl FieldPath {
36    /// The empty path — address of a top-level primitive value.
37    pub const ROOT: Self = Self {
38        segments: Vec::new(),
39    };
40
41    /// Creates a path addressing a single top-level key.
42    #[must_use]
43    pub fn key(name: &str) -> Self {
44        Self {
45            segments: vec![Segment::Key(name.into())],
46        }
47    }
48
49    /// Creates a path addressing a single top-level index.
50    #[must_use]
51    pub fn index(i: usize) -> Self {
52        Self {
53            segments: vec![Segment::Index(i)],
54        }
55    }
56
57    /// Returns a new path extended with one more segment; the receiver is untouched.
58    #[must_use]
59    pub fn join(&self, seg: Segment) -> Self {
60        let mut segments = self.segments.clone();
61        segments.push(seg);
62        Self { segments }
63    }
64}
65
66/// A key renders raw iff it is non-empty and contains none of the trigger
67/// characters (`.`, `[`, `]`, `` ` ``) and no control character; otherwise
68/// it renders backtick-wrapped with an unambiguous, prefix-free escape
69/// grammar: embedded backticks doubled, backslashes doubled, every control
70/// character written `\u{XX}` (lowercase hex, minimum two digits). Raw and
71/// quoted renderings are PREFIX-DISJOINT (backtick is always a trigger), so
72/// the mapping stays injective over structurally distinct keys.
73fn write_key(out: &mut String, k: &str) {
74    use std::fmt::Write as _;
75    if k.is_empty() || k.contains(['.', '[', ']', '`']) || k.chars().any(char::is_control) {
76        out.push('`');
77        for ch in k.chars() {
78            match ch {
79                '`' => out.push_str("``"),
80                '\\' => out.push_str("\\\\"),
81                // Infallible on `String`; uniform lowercase `\u{XX}`, min 2 digits.
82                c if c.is_control() => {
83                    let _ = write!(out, "\\u{{{:02x}}}", c as u32);
84                }
85                c => out.push(c),
86            }
87        }
88        out.push('`');
89    } else {
90        out.push_str(k);
91    }
92}
93
94impl fmt::Display for FieldPath {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        let mut out = String::new();
97        for seg in &self.segments {
98            match seg {
99                Segment::Key(k) => {
100                    if !out.is_empty() {
101                        out.push('.');
102                    }
103                    write_key(&mut out, k);
104                }
105                Segment::Index(i) => {
106                    out.push('[');
107                    out.push_str(&i.to_string());
108                    out.push(']');
109                }
110            }
111        }
112        f.write_str(&out)
113    }
114}
115
116/// Stable, enumerable identifiers for every builtin constraint (ER-3), plus
117/// [`IssueCode::Custom`] for consumer-defined ones.
118///
119/// Matchable today; additive variants in minor releases until v1 freezes it,
120/// so downstream matches should include a wildcard.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub enum IssueCode {
123    /// Value is shorter than the declared minimum length.
124    Min,
125    /// Value exceeds the declared maximum length/bound.
126    Max,
127    /// Length does not equal the declared exact length.
128    Length,
129    /// String does not look like an email address.
130    Email,
131    /// String does not look like a URL.
132    Url,
133    /// String does not look like a UUID.
134    Uuid,
135    /// String is empty where emptiness is forbidden.
136    Empty,
137    /// Number has a fractional part where an integer is required.
138    Int,
139    /// Number is zero or negative where positivity is required.
140    Positive,
141    /// Float is NaN or infinite where finiteness is required.
142    Finite,
143    /// Boolean does not equal the expected value.
144    BoolEquals,
145    /// String-to-type coercion failed.
146    Coerce,
147    /// A refinement closure rejected the value.
148    Refine,
149    /// Erased-view value has the wrong variant for this schema.
150    TypeMismatch,
151    /// Declared object field is absent from the input object (purely
152    /// structural: decided by key existence, never by the value found).
153    Required,
154    /// Consumer-defined constraint identifier, carried verbatim from a
155    /// [`RefineRejection`](crate::rule::RefineRejection).
156    ///
157    /// Builtins never produce this variant; it exists so a consumer running
158    /// several custom rules can tell them apart. [`IssueCode::Refine`]
159    /// collapses every rule into one code, which defeats keying a
160    /// translation table off [`IssueCode`]. The payload is an opaque
161    /// consumer namespace: formars neither interprets nor validates it.
162    Custom(Box<str>),
163}
164
165/// Key of one entry in [`IssueParams`].
166pub type ParamKey = Box<str>;
167
168/// Scalar parameter values carried by issues — dependency-free and ordered.
169#[derive(Clone, Debug, PartialEq)]
170pub enum ParamValue {
171    /// Signed integer parameter.
172    I64(i64),
173    /// Float parameter.
174    F64(f64),
175    /// Boolean parameter.
176    Bool(bool),
177    /// String parameter.
178    Str(Box<str>),
179}
180
181/// Ordered `(key, value)` pairs attached to an issue for deterministic display.
182pub type IssueParams = Vec<(ParamKey, ParamValue)>;
183
184/// One violated constraint: where ([`FieldPath`]), what ([`IssueCode`]),
185/// human-readable message, and structured parameters.
186#[derive(Clone, Debug, PartialEq)]
187pub struct FormaIssue {
188    /// Address of the field under validation (ROOT for top-level primitives;
189    /// dot-joined key segments once validation descends into objects).
190    pub path: FieldPath,
191    /// Stable constraint identifier.
192    pub code: IssueCode,
193    /// Static message for builtins; dynamic allowed for custom rules.
194    pub message: Cow<'static, str>,
195    /// Ordered parameters (e.g. `min = 3`).
196    pub params: IssueParams,
197}
198
199/// Every constraint violated during one parse call, in declaration order (ER-1).
200///
201/// `parse()`/`validate_value()` always return at least one issue on failure,
202/// but the field is public so no non-empty guarantee is claimed at the type
203/// level — hence `first()` returns an [`Option`].
204#[derive(Clone, Debug, Default, PartialEq)]
205pub struct FormaError {
206    /// All issues collected before the error was returned, in declaration order.
207    pub issues: Vec<FormaIssue>,
208}
209
210impl FormaError {
211    /// The earliest-collected issue, or `None` for a hand-constructed empty error.
212    #[must_use]
213    pub fn first(&self) -> Option<&FormaIssue> {
214        self.issues.first()
215    }
216
217    /// Iterates only the issues addressed to `path`.
218    pub fn issues_for<'a>(&'a self, path: &'a FieldPath) -> impl Iterator<Item = &'a FormaIssue> {
219        self.issues.iter().filter(move |i| &i.path == path)
220    }
221}
222
223impl fmt::Display for FormaError {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        match self.issues.first() {
226            Some(first) => {
227                write!(
228                    f,
229                    "{} validation issue(s); first: {}",
230                    self.issues.len(),
231                    first.message
232                )
233            }
234            None => f.write_str("0 validation issues"),
235        }
236    }
237}
238
239impl std::error::Error for FormaError {}
240
241/// Internal accumulator consulted by validation kernels.
242pub(crate) struct Sink<'p> {
243    path: &'p FieldPath,
244    fail_fast: bool,
245    /// Collected issues so far.
246    pub issues: Vec<FormaIssue>,
247}
248
249impl<'p> Sink<'p> {
250    pub(crate) fn new(path: &'p FieldPath, fail_fast: bool) -> Self {
251        Self {
252            path,
253            fail_fast,
254            issues: Vec::new(),
255        }
256    }
257
258    /// Appends the issue (stamping it with the sink's path); returns `false`
259    /// when a fail-fast stop is signaled — kernels break their loop on `false`,
260    /// which is what guarantees exactly one issue under `.fail_fast()`.
261    pub(crate) fn push(&mut self, mut issue: FormaIssue) -> bool {
262        issue.path = self.path.clone();
263        self.issues.push(issue);
264        !self.fail_fast
265    }
266
267    /// Consumes the sink into the final error.
268    pub(crate) fn finish(self) -> FormaError {
269        FormaError {
270            issues: self.issues,
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::{FieldPath, FormaError, FormaIssue, IssueCode, ParamValue, Segment, Sink};
278
279    fn issue(path: FieldPath, code: IssueCode, msg: &'static str) -> FormaIssue {
280        FormaIssue {
281            path,
282            code,
283            message: msg.into(),
284            params: Vec::new(),
285        }
286    }
287
288    #[test]
289    fn display_root_is_empty() {
290        assert_eq!(FieldPath::ROOT.to_string(), "");
291    }
292
293    #[test]
294    fn display_single_key() {
295        assert_eq!(FieldPath::key("email").to_string(), "email");
296    }
297
298    #[test]
299    fn display_nested_keys() {
300        let p = FieldPath::key("user").join(Segment::Key("email".into()));
301        assert_eq!(p.to_string(), "user.email");
302    }
303
304    #[test]
305    fn display_mixed_key_index() {
306        let p = FieldPath::key("items")
307            .join(Segment::Index(2))
308            .join(Segment::Key("qty".into()));
309        assert_eq!(p.to_string(), "items[2].qty");
310    }
311
312    #[test]
313    fn join_does_not_mutate_receiver() {
314        let base = FieldPath::key("user");
315        let child = base.join(Segment::Key("email".into()));
316        assert_eq!(base.to_string(), "user");
317        assert_eq!(child.to_string(), "user.email");
318    }
319
320    #[test]
321    fn paths_with_equal_segments_are_equal() {
322        assert_eq!(FieldPath::key("a"), FieldPath::key("a"));
323        assert_ne!(FieldPath::key("a"), FieldPath::index(0));
324    }
325
326    #[test]
327    fn er2_first_returns_earliest() {
328        let e = FormaError {
329            issues: vec![
330                issue(FieldPath::ROOT, IssueCode::Min, "min"),
331                issue(FieldPath::ROOT, IssueCode::Email, "email"),
332            ],
333        };
334        assert_eq!(e.first().unwrap().code, IssueCode::Min);
335        assert_eq!(e.first().unwrap().message, "min");
336    }
337
338    #[test]
339    fn er2_issues_for_path_lookup() {
340        let email_path = FieldPath::key("email");
341        let e = FormaError {
342            issues: vec![
343                issue(email_path.clone(), IssueCode::Email, "bad email"),
344                issue(FieldPath::key("age"), IssueCode::Min, "too young"),
345                issue(email_path.clone(), IssueCode::Max, "long"),
346            ],
347        };
348        let got: Vec<_> = e.issues_for(&email_path).map(|i| i.code.clone()).collect();
349        assert_eq!(got, vec![IssueCode::Email, IssueCode::Max]);
350    }
351
352    #[test]
353    fn first_is_none_for_empty_error() {
354        assert!(FormaError { issues: Vec::new() }.first().is_none());
355    }
356
357    #[test]
358    fn error_display_mentions_count_and_first_message() {
359        let e = FormaError {
360            issues: vec![issue(FieldPath::ROOT, IssueCode::Min, "too short")],
361        };
362        let d = e.to_string();
363        assert!(d.contains('1'), "count missing: {d}");
364        assert!(d.contains("too short"), "first message missing: {d}");
365    }
366
367    #[test]
368    fn error_display_none_safe_when_empty() {
369        let d = FormaError { issues: Vec::new() }.to_string();
370        assert!(!d.is_empty());
371    }
372
373    #[test]
374    fn issue_params_preserve_order_and_values() {
375        let i = FormaIssue {
376            path: FieldPath::ROOT,
377            code: IssueCode::Min,
378            message: "min".into(),
379            params: vec![
380                ("min".into(), ParamValue::I64(3)),
381                ("got".into(), ParamValue::Str("a".into())),
382            ],
383        };
384        assert_eq!(i.params[0].0.as_ref(), "min");
385        assert_eq!(i.params[1].1, ParamValue::Str("a".into()));
386    }
387
388    #[test]
389    fn issue_code_is_matchable_and_comparable() {
390        let c = IssueCode::Coerce;
391        assert_eq!(c, IssueCode::Coerce);
392        assert_ne!(c, IssueCode::Refine);
393        let name = match c {
394            IssueCode::Coerce => "coerce",
395            _ => "other",
396        };
397        assert_eq!(name, "coerce");
398    }
399
400    #[test]
401    fn sink_accumulate_collects_all() {
402        let root = FieldPath::ROOT;
403        let mut s = Sink::new(&root, false);
404        assert!(s.push(issue(FieldPath::ROOT, IssueCode::Min, "a")));
405        assert!(s.push(issue(FieldPath::ROOT, IssueCode::Email, "b")));
406        assert_eq!(s.issues.len(), 2);
407        assert_eq!(s.finish().issues.len(), 2);
408    }
409
410    #[test]
411    fn er6_required_code_is_matchable_and_displays_sensibly() {
412        let c = IssueCode::Required;
413        assert_eq!(c, IssueCode::Required);
414        assert_ne!(c, IssueCode::Min);
415        let name = match c {
416            IssueCode::Required => "required",
417            _ => "other",
418        };
419        assert_eq!(name, "required");
420    }
421
422    #[test]
423    fn er6_required_carries_joined_path_in_issue() {
424        let p = FieldPath::key("email");
425        let i = issue(p.clone(), IssueCode::Required, "missing");
426        assert_eq!(i.path, p);
427        assert_eq!(i.path.to_string(), "email");
428    }
429
430    #[test]
431    fn custom_codes_stay_distinguishable_from_each_other_and_from_refine() {
432        let decimal = IssueCode::Custom("decimal".into());
433        let currency = IssueCode::Custom("currency".into());
434        assert_ne!(decimal, currency);
435        assert_ne!(decimal, IssueCode::Refine);
436        let key = match &decimal {
437            IssueCode::Custom(name) => format!("validation.{name}"),
438            _ => "validation.generic".to_owned(),
439        };
440        assert_eq!(key, "validation.decimal");
441    }
442
443    #[test]
444    fn sink_fail_fast_signals_stop_after_first_push() {
445        let root = FieldPath::ROOT;
446        let mut s = Sink::new(&root, true);
447        assert!(!s.push(issue(FieldPath::ROOT, IssueCode::Min, "a")));
448        assert_eq!(s.finish().issues.len(), 1);
449    }
450}