Skip to main content

rusdantic_core/
error.rs

1//! Error types for validation results.
2//!
3//! Provides [`ValidationError`] for individual field errors and
4//! [`ValidationErrors`] for collecting multiple errors across a struct.
5//! All types are JSON-serializable for API error responses and implement
6//! `Display` for human-readable output.
7
8use serde::Serialize;
9use std::collections::HashMap;
10use std::fmt;
11
12/// A single validation error with location context.
13///
14/// Each error includes the path to the invalid field (supporting nested structs
15/// and collection indices), a machine-readable error code, a human-readable
16/// message, and optional constraint parameters for structured error reporting.
17///
18/// # Example
19///
20/// ```
21/// use rusdantic_core::{ValidationError, PathSegment};
22///
23/// let error = ValidationError {
24///     path: vec![
25///         PathSegment::Field("user".to_string()),
26///         PathSegment::Field("email".to_string()),
27///     ],
28///     code: "email".to_string(),
29///     message: "invalid email format".to_string(),
30///     params: Default::default(),
31/// };
32///
33/// assert_eq!(error.path_string(), "user.email");
34/// ```
35#[derive(Debug, Clone, PartialEq, Serialize)]
36pub struct ValidationError {
37    /// Path to the invalid field.
38    ///
39    /// For nested structs, this contains multiple segments:
40    /// `["user", "addresses", 0, "zip_code"]`
41    pub path: Vec<PathSegment>,
42
43    /// Machine-readable error code.
44    ///
45    /// Standard codes include: `"length_min"`, `"length_max"`, `"range_min"`,
46    /// `"range_max"`, `"email"`, `"url"`, `"pattern"`, `"contains"`, `"required"`,
47    /// `"custom"`.
48    pub code: String,
49
50    /// Human-readable error message suitable for display to end users.
51    pub message: String,
52
53    /// Constraint parameters providing context about the validation failure.
54    ///
55    /// For example, a length violation might include:
56    /// `{"min": 3, "max": 20, "actual": 1}`
57    #[serde(skip_serializing_if = "HashMap::is_empty")]
58    pub params: HashMap<String, serde_json::Value>,
59}
60
61impl ValidationError {
62    /// Create a new validation error with the given code and message.
63    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
64        Self {
65            path: Vec::new(),
66            code: code.into(),
67            message: message.into(),
68            params: HashMap::new(),
69        }
70    }
71
72    /// Add a parameter to this error for structured error reporting.
73    pub fn with_param(
74        mut self,
75        key: impl Into<String>,
76        value: impl Into<serde_json::Value>,
77    ) -> Self {
78        self.params.insert(key.into(), value.into());
79        self
80    }
81
82    /// Set the path for this error.
83    pub fn with_path(mut self, path: Vec<PathSegment>) -> Self {
84        self.path = path;
85        self
86    }
87
88    /// Format the path as a dot-notation string.
89    ///
90    /// Field segments are joined with `.`, index segments are formatted as `[N]`.
91    /// Example: `"user.addresses[0].zip_code"`
92    pub fn path_string(&self) -> String {
93        let mut result = String::new();
94        for (i, segment) in self.path.iter().enumerate() {
95            match segment {
96                PathSegment::Field(name) => {
97                    if i > 0 {
98                        result.push('.');
99                    }
100                    result.push_str(name);
101                }
102                PathSegment::Index(idx) => {
103                    result.push('[');
104                    result.push_str(&idx.to_string());
105                    result.push(']');
106                }
107            }
108        }
109        result
110    }
111}
112
113impl fmt::Display for ValidationError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        let path = self.path_string();
116        if path.is_empty() {
117            write!(f, "{} ({})", self.message, self.code)
118        } else {
119            write!(f, "{}: {} ({})", path, self.message, self.code)
120        }
121    }
122}
123
124impl std::error::Error for ValidationError {}
125
126/// Path segment in a validation error location.
127///
128/// Supports both field names (for struct fields and map keys) and
129/// numeric indices (for arrays/vectors).
130#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
131#[serde(untagged)]
132pub enum PathSegment {
133    /// A named field in a struct or map key.
134    Field(String),
135    /// A numeric index in an array or vector.
136    Index(usize),
137}
138
139/// A collection of validation errors from validating a struct.
140///
141/// This type aggregates all validation errors found during validation,
142/// allowing users to see all problems at once rather than fixing them
143/// one at a time. It implements both JSON serialization (for API responses)
144/// and human-readable `Display` (for CLI/logging output).
145///
146/// # Example
147///
148/// ```
149/// use rusdantic_core::{ValidationErrors, ValidationError, PathSegment};
150///
151/// let mut errors = ValidationErrors::new();
152/// assert!(errors.is_empty());
153///
154/// errors.add(ValidationError {
155///     path: vec![PathSegment::Field("email".to_string())],
156///     code: "email".to_string(),
157///     message: "invalid email format".to_string(),
158///     params: Default::default(),
159/// });
160///
161/// assert_eq!(errors.len(), 1);
162/// assert!(!errors.is_empty());
163/// ```
164#[derive(Debug, Clone, Serialize)]
165pub struct ValidationErrors {
166    /// The collected validation errors.
167    errors: Vec<ValidationError>,
168}
169
170impl ValidationErrors {
171    /// Create an empty error collection.
172    pub fn new() -> Self {
173        Self {
174            errors: Vec::new(),
175        }
176    }
177
178    /// Add a single validation error to the collection.
179    pub fn add(&mut self, error: ValidationError) {
180        self.errors.push(error);
181    }
182
183    /// Merge another `ValidationErrors` into this one.
184    /// All errors from `other` are moved into this collection.
185    pub fn merge(&mut self, other: ValidationErrors) {
186        self.errors.extend(other.errors);
187    }
188
189    /// Check if no errors have been collected.
190    pub fn is_empty(&self) -> bool {
191        self.errors.is_empty()
192    }
193
194    /// Return the number of collected errors.
195    pub fn len(&self) -> usize {
196        self.errors.len()
197    }
198
199    /// Get a reference to the collected errors.
200    pub fn errors(&self) -> &[ValidationError] {
201        &self.errors
202    }
203
204    /// Consume self and return the inner vector of errors.
205    pub fn into_errors(self) -> Vec<ValidationError> {
206        self.errors
207    }
208
209    /// Get errors for a specific field path (first segment only).
210    pub fn field_errors(&self, field_name: &str) -> Vec<&ValidationError> {
211        self.errors
212            .iter()
213            .filter(|e| {
214                e.path
215                    .first()
216                    .map(|s| matches!(s, PathSegment::Field(name) if name == field_name))
217                    .unwrap_or(false)
218            })
219            .collect()
220    }
221}
222
223impl Default for ValidationErrors {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl fmt::Display for ValidationErrors {
230    /// Display all errors in a human-readable format, one per line.
231    ///
232    /// Example output:
233    /// ```text
234    /// Validation failed with 2 error(s):
235    ///   - username: must be between 3 and 20 characters (length)
236    ///   - email: invalid email format (email)
237    /// ```
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        writeln!(
240            f,
241            "Validation failed with {} error(s):",
242            self.errors.len()
243        )?;
244        for error in &self.errors {
245            writeln!(f, "  - {}", error)?;
246        }
247        Ok(())
248    }
249}
250
251impl std::error::Error for ValidationErrors {}
252
253// Implement IntoIterator for ergonomic error iteration
254impl IntoIterator for ValidationErrors {
255    type Item = ValidationError;
256    type IntoIter = std::vec::IntoIter<ValidationError>;
257
258    fn into_iter(self) -> Self::IntoIter {
259        self.errors.into_iter()
260    }
261}
262
263impl<'a> IntoIterator for &'a ValidationErrors {
264    type Item = &'a ValidationError;
265    type IntoIter = std::slice::Iter<'a, ValidationError>;
266
267    fn into_iter(self) -> Self::IntoIter {
268        self.errors.iter()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_validation_error_display_with_path() {
278        let error = ValidationError {
279            path: vec![
280                PathSegment::Field("user".to_string()),
281                PathSegment::Field("email".to_string()),
282            ],
283            code: "email".to_string(),
284            message: "invalid email format".to_string(),
285            params: HashMap::new(),
286        };
287        assert_eq!(
288            error.to_string(),
289            "user.email: invalid email format (email)"
290        );
291    }
292
293    #[test]
294    fn test_validation_error_display_with_index() {
295        let error = ValidationError {
296            path: vec![
297                PathSegment::Field("users".to_string()),
298                PathSegment::Index(3),
299                PathSegment::Field("name".to_string()),
300            ],
301            code: "length_min".to_string(),
302            message: "must be at least 1 character".to_string(),
303            params: HashMap::new(),
304        };
305        assert_eq!(
306            error.to_string(),
307            "users[3].name: must be at least 1 character (length_min)"
308        );
309    }
310
311    #[test]
312    fn test_validation_error_display_without_path() {
313        let error = ValidationError::new("custom", "cross-field validation failed");
314        assert_eq!(
315            error.to_string(),
316            "cross-field validation failed (custom)"
317        );
318    }
319
320    #[test]
321    fn test_path_string_empty() {
322        let error = ValidationError::new("test", "test");
323        assert_eq!(error.path_string(), "");
324    }
325
326    #[test]
327    fn test_path_string_nested() {
328        let error = ValidationError {
329            path: vec![
330                PathSegment::Field("a".to_string()),
331                PathSegment::Field("b".to_string()),
332                PathSegment::Index(0),
333                PathSegment::Field("c".to_string()),
334            ],
335            code: "test".to_string(),
336            message: "test".to_string(),
337            params: HashMap::new(),
338        };
339        assert_eq!(error.path_string(), "a.b[0].c");
340    }
341
342    #[test]
343    fn test_validation_errors_empty() {
344        let errors = ValidationErrors::new();
345        assert!(errors.is_empty());
346        assert_eq!(errors.len(), 0);
347    }
348
349    #[test]
350    fn test_validation_errors_add_and_len() {
351        let mut errors = ValidationErrors::new();
352        errors.add(ValidationError::new("a", "error a"));
353        errors.add(ValidationError::new("b", "error b"));
354        assert_eq!(errors.len(), 2);
355        assert!(!errors.is_empty());
356    }
357
358    #[test]
359    fn test_validation_errors_merge() {
360        let mut errors1 = ValidationErrors::new();
361        errors1.add(ValidationError::new("a", "error a"));
362
363        let mut errors2 = ValidationErrors::new();
364        errors2.add(ValidationError::new("b", "error b"));
365        errors2.add(ValidationError::new("c", "error c"));
366
367        errors1.merge(errors2);
368        assert_eq!(errors1.len(), 3);
369    }
370
371    #[test]
372    fn test_validation_errors_field_errors() {
373        let mut errors = ValidationErrors::new();
374        errors.add(
375            ValidationError::new("email", "invalid")
376                .with_path(vec![PathSegment::Field("email".to_string())]),
377        );
378        errors.add(
379            ValidationError::new("length", "too short")
380                .with_path(vec![PathSegment::Field("name".to_string())]),
381        );
382        errors.add(
383            ValidationError::new("email", "duplicate")
384                .with_path(vec![PathSegment::Field("email".to_string())]),
385        );
386
387        let email_errors = errors.field_errors("email");
388        assert_eq!(email_errors.len(), 2);
389
390        let name_errors = errors.field_errors("name");
391        assert_eq!(name_errors.len(), 1);
392
393        let unknown_errors = errors.field_errors("unknown");
394        assert_eq!(unknown_errors.len(), 0);
395    }
396
397    #[test]
398    fn test_validation_errors_json_serialization() {
399        let mut errors = ValidationErrors::new();
400        errors.add(
401            ValidationError::new("length_min", "must be at least 3 characters")
402                .with_path(vec![PathSegment::Field("username".to_string())])
403                .with_param("min", serde_json::json!(3))
404                .with_param("actual", serde_json::json!(1)),
405        );
406
407        let json = serde_json::to_string(&errors).unwrap();
408        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
409
410        let first_error = &parsed["errors"][0];
411        assert_eq!(first_error["code"], "length_min");
412        assert_eq!(first_error["path"][0], "username");
413        assert_eq!(first_error["params"]["min"], 3);
414    }
415
416    #[test]
417    fn test_validation_errors_display() {
418        let mut errors = ValidationErrors::new();
419        errors.add(
420            ValidationError::new("email", "invalid email format")
421                .with_path(vec![PathSegment::Field("email".to_string())]),
422        );
423        let display = errors.to_string();
424        assert!(display.contains("Validation failed with 1 error(s)"));
425        assert!(display.contains("email: invalid email format (email)"));
426    }
427
428    #[test]
429    fn test_validation_errors_into_iterator() {
430        let mut errors = ValidationErrors::new();
431        errors.add(ValidationError::new("a", "A"));
432        errors.add(ValidationError::new("b", "B"));
433
434        let codes: Vec<String> = errors.into_iter().map(|e| e.code).collect();
435        assert_eq!(codes, vec!["a", "b"]);
436    }
437
438    #[test]
439    fn test_validation_error_with_param() {
440        let error = ValidationError::new("length_min", "too short")
441            .with_param("min", serde_json::json!(3))
442            .with_param("actual", serde_json::json!(1));
443
444        assert_eq!(error.params.len(), 2);
445        assert_eq!(error.params["min"], serde_json::json!(3));
446        assert_eq!(error.params["actual"], serde_json::json!(1));
447    }
448
449    #[test]
450    fn test_params_skip_serializing_if_empty() {
451        let error = ValidationError::new("email", "invalid");
452        let json = serde_json::to_string(&error).unwrap();
453        // params should not appear in JSON when empty
454        assert!(!json.contains("params"));
455    }
456}