Skip to main content

siderite_validation/
error.rs

1//! Structured validation errors with Pydantic-style location paths.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use serde::ser::SerializeStruct;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// One segment of a field location path (`loc` in Pydantic).
11///
12/// Serialized untagged, so a key becomes a JSON string and an index a JSON number.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum LocationItem {
16    /// Object key / field name.
17    Key(String),
18    /// Array index.
19    Index(usize),
20}
21
22impl LocationItem {
23    /// Object-key location segment.
24    pub fn key(key: impl Into<String>) -> Self {
25        Self::Key(key.into())
26    }
27
28    /// Array-index location segment.
29    pub fn index(index: usize) -> Self {
30        Self::Index(index)
31    }
32}
33
34impl fmt::Display for LocationItem {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::Key(key) => f.write_str(key),
38            Self::Index(index) => write!(f, "{index}"),
39        }
40    }
41}
42
43impl From<String> for LocationItem {
44    fn from(key: String) -> Self {
45        Self::Key(key)
46    }
47}
48
49impl From<&str> for LocationItem {
50    fn from(key: &str) -> Self {
51        Self::Key(key.to_owned())
52    }
53}
54
55impl From<usize> for LocationItem {
56    fn from(index: usize) -> Self {
57        Self::Index(index)
58    }
59}
60
61/// A single field-level validation failure.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
63#[error("{code}: {message}")]
64pub struct FieldError {
65    /// Path from the validated root to the failing value.
66    pub location: Vec<LocationItem>,
67    /// Stable machine-readable error code.
68    pub code: Cow<'static, str>,
69    /// Human-readable explanation.
70    pub message: String,
71}
72
73impl FieldError {
74    /// Create a field error with an empty location path.
75    pub fn new(code: impl Into<Cow<'static, str>>, message: impl Into<String>) -> Self {
76        Self {
77            location: Vec::new(),
78            code: code.into(),
79            message: message.into(),
80        }
81    }
82
83    /// Prepend a location segment so nested errors can bubble toward the root.
84    #[must_use]
85    pub fn at(mut self, item: impl Into<LocationItem>) -> Self {
86        self.location.insert(0, item.into());
87        self
88    }
89}
90
91/// Aggregate of one or more [`FieldError`] values.
92///
93/// Serializes as:
94///
95/// ```json
96/// {"type":"validation_error","errors":[...]}
97/// ```
98#[derive(Debug, Clone, PartialEq, Default, Error)]
99#[error("validation failed with {n} error(s)", n = self.errors.len())]
100pub struct ValidationError {
101    /// Collected field-level violations.
102    pub errors: Vec<FieldError>,
103}
104
105impl ValidationError {
106    /// Create an empty error list.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Append a field error.
112    pub fn push(&mut self, error: FieldError) {
113        self.errors.push(error);
114    }
115
116    /// Whether no field errors have been collected.
117    pub fn is_empty(&self) -> bool {
118        self.errors.is_empty()
119    }
120
121    /// Append all errors from `other`.
122    pub fn merge(&mut self, other: Self) {
123        self.errors.extend(other.errors);
124    }
125
126    /// Prepend `item` to the location of every contained error.
127    #[must_use]
128    pub fn prefixed(self, item: impl Into<LocationItem>) -> Self {
129        let item = item.into();
130        Self {
131            errors: self
132                .errors
133                .into_iter()
134                .map(|error| error.at(item.clone()))
135                .collect(),
136        }
137    }
138
139    /// Convert into `Ok(())` when empty, otherwise `Err(self)`.
140    pub fn into_result(self) -> Result<(), Self> {
141        if self.is_empty() { Ok(()) } else { Err(self) }
142    }
143}
144
145impl From<FieldError> for ValidationError {
146    fn from(error: FieldError) -> Self {
147        Self {
148            errors: vec![error],
149        }
150    }
151}
152
153impl Serialize for ValidationError {
154    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
155    where
156        S: serde::Serializer,
157    {
158        let mut state = serializer.serialize_struct("ValidationError", 2)?;
159        state.serialize_field("type", "validation_error")?;
160        state.serialize_field("errors", &self.errors)?;
161        state.end()
162    }
163}
164
165/// Result of a validation operation.
166pub type ValidationResult<T> = Result<T, ValidationError>;
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn at_prepends_location_segments() {
174        let error = FieldError::new("invalid_email", "invalid email address")
175            .at(LocationItem::key("email"))
176            .at(LocationItem::key("body"));
177        assert_eq!(
178            error.location,
179            vec![LocationItem::key("body"), LocationItem::key("email")]
180        );
181    }
182
183    #[test]
184    fn serialize_matches_documented_shape() {
185        let mut error = ValidationError::new();
186        error.push(
187            FieldError::new("invalid_email", "invalid email address")
188                .at("email")
189                .at("body"),
190        );
191        let json = serde_json::to_value(&error).unwrap();
192        assert_eq!(
193            json,
194            serde_json::json!({
195                "type": "validation_error",
196                "errors": [{
197                    "location": ["body", "email"],
198                    "code": "invalid_email",
199                    "message": "invalid email address"
200                }]
201            })
202        );
203    }
204
205    #[test]
206    fn location_index_serializes_as_number() {
207        let error = FieldError::new("greater_than", "too small").at(LocationItem::index(0));
208        let json = serde_json::to_value(&error).unwrap();
209        assert_eq!(json["location"], serde_json::json!([0]));
210    }
211
212    #[test]
213    fn location_item_untagged_round_trip() {
214        let key: LocationItem = serde_json::from_value(serde_json::json!("email")).unwrap();
215        let index: LocationItem = serde_json::from_value(serde_json::json!(3)).unwrap();
216        assert_eq!(key, LocationItem::key("email"));
217        assert_eq!(index, LocationItem::index(3));
218    }
219
220    #[test]
221    fn into_result_ok_when_empty() {
222        assert!(ValidationError::new().into_result().is_ok());
223    }
224
225    #[test]
226    fn into_result_err_when_nonempty() {
227        let mut error = ValidationError::new();
228        error.push(FieldError::new("invalid_email", "invalid email address"));
229        assert!(error.into_result().is_err());
230    }
231
232    #[test]
233    fn merge_appends_errors() {
234        let mut left = ValidationError::from(FieldError::new("a", "A"));
235        left.merge(ValidationError::from(FieldError::new("b", "B")));
236        assert_eq!(left.errors.len(), 2);
237        assert_eq!(left.errors[0].code, "a");
238        assert_eq!(left.errors[1].code, "b");
239    }
240
241    #[test]
242    fn prefixed_prepends_to_every_error() {
243        let mut error = ValidationError::new();
244        error.push(FieldError::new("x", "X").at("email"));
245        error.push(FieldError::new("y", "Y").at("name"));
246        let error = error.prefixed("body");
247        assert_eq!(
248            error.errors[0].location,
249            vec![LocationItem::key("body"), LocationItem::key("email")]
250        );
251        assert_eq!(
252            error.errors[1].location,
253            vec![LocationItem::key("body"), LocationItem::key("name")]
254        );
255    }
256
257    #[test]
258    fn is_empty_tracks_contents() {
259        let mut error = ValidationError::new();
260        assert!(error.is_empty());
261        error.push(FieldError::new("x", "X"));
262        assert!(!error.is_empty());
263    }
264
265    #[test]
266    fn display_and_error_trait() {
267        let error =
268            ValidationError::from(FieldError::new("invalid_email", "invalid email address"));
269        assert_eq!(error.to_string(), "validation failed with 1 error(s)");
270        let _: &dyn std::error::Error = &error;
271        assert_eq!(
272            error.errors[0].to_string(),
273            "invalid_email: invalid email address"
274        );
275    }
276}