Skip to main content

tanzim_validate/
error.rs

1use crate::Meta;
2use std::fmt::{self, Display, Formatter};
3use tanzim_value::{Location, ValueType};
4
5/// One step in the path from the validated root to the offending value.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Segment {
8    /// A map key (`static_map` / `dynamic_map`).
9    Key(String),
10    /// A list index.
11    Index(usize),
12}
13
14/// What went wrong while validating a value.
15#[derive(Debug, Clone, PartialEq)]
16pub enum ErrorKind {
17    /// Wrong variant and no coercion applies.
18    Type {
19        expected: ValueType,
20        found: ValueType,
21    },
22    /// Right variant family but the contents cannot be coerced to the target.
23    NotConvertible { target: ValueType, found: ValueType },
24    /// A semantic format check failed (host, email, uuid, …).
25    Format { expected: &'static str },
26    /// Numeric value below the inclusive minimum.
27    BelowMin { value: String, min: String },
28    /// Numeric value above the inclusive maximum.
29    AboveMax { value: String, max: String },
30    /// String/list/map shorter than the minimum length.
31    TooShort { len: usize, min: usize },
32    /// String/list/map longer than the maximum length.
33    TooLong { len: usize, max: usize },
34    /// String did not match the required pattern.
35    PatternMismatch { pattern: String },
36    /// A duplicate item was found in a list required to be unique.
37    Duplicate { index: usize },
38    /// A required key was missing from a map.
39    MissingKey { key: String },
40    /// A key not declared in the schema was present in a map.
41    UnknownKey { key: String },
42    /// A value was not in the allow-list (`Enum`).
43    NotAllowed { value: String },
44    /// Neither alternative of an `Either` accepted the value.
45    Either {
46        first: Box<Error>,
47        second: Box<Error>,
48    },
49}
50
51impl Display for ErrorKind {
52    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Type { expected, found } => {
55                write!(f, "expected {expected}, found {found}")
56            }
57            Self::NotConvertible { target, found } => {
58                write!(f, "cannot convert {found} to {target}")
59            }
60            Self::Format { expected } => write!(f, "invalid {expected}"),
61            Self::BelowMin { value, min } => write!(f, "{value} is below the minimum {min}"),
62            Self::AboveMax { value, max } => write!(f, "{value} is above the maximum {max}"),
63            Self::TooShort { len, min } => {
64                write!(f, "length {len} is below the minimum {min}")
65            }
66            Self::TooLong { len, max } => write!(f, "length {len} is above the maximum {max}"),
67            Self::PatternMismatch { pattern } => {
68                write!(f, "does not match pattern `{pattern}`")
69            }
70            Self::Duplicate { index } => write!(f, "duplicate item at index {index}"),
71            Self::MissingKey { key } => write!(f, "missing required key `{key}`"),
72            Self::UnknownKey { key } => write!(f, "unknown key `{key}`"),
73            Self::NotAllowed { value } => write!(f, "`{value}` is not an allowed value"),
74            Self::Either { first, second } => {
75                write!(f, "no alternative matched: ({first}) or ({second})")
76            }
77        }
78    }
79}
80
81/// A validation failure, carrying a breadcrumb path and (when known) the source
82/// [`Location`] of the offending value.
83///
84/// [`Display`] is one line by default; use `{error:#}` for the location's caret view.
85#[derive(Debug, Clone, PartialEq)]
86pub struct Error {
87    pub kind: ErrorKind,
88    /// Path from the validated root to the offending value (root-first).
89    pub path: Vec<Segment>,
90    /// Source location, filled in by the enclosing value that owns it.
91    ///
92    /// Boxed to keep [`Error`] small enough to return by value (`clippy::result_large_err`).
93    pub location: Option<Box<Location>>,
94    /// The failing validator's human-facing metadata (name/description/examples/default).
95    ///
96    /// Boxed for the same size reason as `location`; filled in by the validator that failed
97    /// (innermost wins).
98    pub meta: Option<Box<Meta>>,
99}
100
101impl Error {
102    /// Build a path-less, location-less error for the value currently being validated.
103    pub fn new(kind: ErrorKind) -> Self {
104        Self {
105            kind,
106            path: Vec::new(),
107            location: None,
108            meta: None,
109        }
110    }
111
112    /// Attach `location` unless one is already set (the innermost owner wins).
113    pub fn with_location(mut self, location: &Location) -> Self {
114        if self.location.is_none() {
115            self.location = Some(Box::new(location.clone()));
116        }
117        self
118    }
119
120    /// Attach the failing validator's [`Meta`] unless one is already set (innermost wins).
121    pub fn with_meta(mut self, meta: &Meta) -> Self {
122        if self.meta.is_none() {
123            self.meta = Some(Box::new(meta.clone()));
124        }
125        self
126    }
127
128    /// The failing validator's name, if known.
129    pub fn name(&self) -> Option<&str> {
130        self.meta.as_ref().map(|meta| meta.name.as_str())
131    }
132
133    /// The failing validator's default value, if it declared one.
134    pub fn default_value(&self) -> Option<&tanzim_value::Value> {
135        self.meta.as_ref().and_then(|meta| meta.default.as_ref())
136    }
137
138    /// Record that this error happened under map key `key`, whose value lives at `location`.
139    pub fn under_key(mut self, key: &str, location: &Location) -> Self {
140        self.path.insert(0, Segment::Key(key.to_string()));
141        self.with_location(location)
142    }
143
144    /// Record that this error happened under list index `index`, whose item lives at `location`.
145    pub fn under_index(mut self, index: usize, location: &Location) -> Self {
146        self.path.insert(0, Segment::Index(index));
147        self.with_location(location)
148    }
149
150    fn write_path(&self, f: &mut Formatter<'_>) -> fmt::Result {
151        for (position, segment) in self.path.iter().enumerate() {
152            match segment {
153                Segment::Key(key) => {
154                    if position > 0 {
155                        write!(f, ".")?;
156                    }
157                    write!(f, "{key}")?;
158                }
159                Segment::Index(index) => write!(f, "[{index}]")?,
160            }
161        }
162        Ok(())
163    }
164}
165
166impl Display for Error {
167    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
168        if let Some(meta) = &self.meta
169            && !meta.name.is_empty()
170        {
171            write!(f, "{}: ", meta.name)?;
172        }
173        if !self.path.is_empty() {
174            self.write_path(f)?;
175            write!(f, ": ")?;
176        }
177        write!(f, "{}", self.kind)?;
178        if let Some(location) = &self.location {
179            write!(f, " at {location}")?;
180        }
181        if f.alternate()
182            && let Some(meta) = &self.meta
183        {
184            if let Some(description) = &meta.description {
185                write!(f, "\n  {description}")?;
186            }
187            for (value, note) in &meta.examples {
188                match note {
189                    Some(note) => write!(f, "\n  example: {value} ({note})")?,
190                    None => write!(f, "\n  example: {value}")?,
191                }
192            }
193        }
194        Ok(())
195    }
196}
197
198impl std::error::Error for Error {}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use tanzim_value::Location;
204
205    #[test]
206    fn nested_error_renders_path_and_innermost_location() {
207        let leaf_loc = Location::at("file", "config.yaml", Some(3), Some(9), None);
208        let outer_loc = Location::at("file", "config.yaml", Some(2), Some(1), None);
209        let error = Error::new(ErrorKind::Type {
210            expected: ValueType::Int,
211            found: ValueType::String,
212        })
213        .under_key("port", &leaf_loc)
214        .under_index(0, &outer_loc)
215        .under_key("servers", &outer_loc);
216
217        let message = error.to_string();
218        assert!(message.starts_with("servers[0].port: expected integer, found string"));
219        // innermost (leaf) location wins
220        assert!(message.contains("config.yaml:3:9"));
221    }
222}