acton_htmx/forms/
error.rs

1//! Form validation error types
2//!
3//! Provides error types for form validation that integrate with
4//! the `validator` crate and support HTMX partial updates.
5
6use std::collections::HashMap;
7
8/// A single validation error for a field
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct FieldError {
11    /// The error message
12    pub message: String,
13    /// Optional error code for programmatic handling
14    pub code: Option<String>,
15}
16
17impl FieldError {
18    /// Create a new field error with just a message
19    #[must_use]
20    pub fn new(message: impl Into<String>) -> Self {
21        Self {
22            message: message.into(),
23            code: None,
24        }
25    }
26
27    /// Create a field error with a message and code
28    #[must_use]
29    pub fn with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
30        Self {
31            message: message.into(),
32            code: Some(code.into()),
33        }
34    }
35}
36
37impl std::fmt::Display for FieldError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "{}", self.message)
40    }
41}
42
43/// Collection of validation errors keyed by field name
44///
45/// # Examples
46///
47/// ```rust
48/// use acton_htmx::forms::ValidationErrors;
49///
50/// let mut errors = ValidationErrors::new();
51/// errors.add("email", "is required");
52/// errors.add("email", "must be a valid email address");
53/// errors.add("password", "must be at least 8 characters");
54///
55/// assert!(errors.has_errors());
56/// assert_eq!(errors.for_field("email").len(), 2);
57/// ```
58#[derive(Debug, Clone, Default)]
59pub struct ValidationErrors {
60    errors: HashMap<String, Vec<FieldError>>,
61}
62
63impl ValidationErrors {
64    /// Create a new empty error collection
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Add an error for a field
71    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
72        let field = field.into();
73        self.errors
74            .entry(field)
75            .or_default()
76            .push(FieldError::new(message));
77    }
78
79    /// Add an error with a code for a field
80    pub fn add_with_code(
81        &mut self,
82        field: impl Into<String>,
83        message: impl Into<String>,
84        code: impl Into<String>,
85    ) {
86        let field = field.into();
87        self.errors
88            .entry(field)
89            .or_default()
90            .push(FieldError::with_code(message, code));
91    }
92
93    /// Check if there are any errors
94    #[must_use]
95    pub fn has_errors(&self) -> bool {
96        !self.errors.is_empty()
97    }
98
99    /// Check if a specific field has errors
100    #[must_use]
101    pub fn has_field_error(&self, field: &str) -> bool {
102        self.errors.contains_key(field)
103    }
104
105    /// Get all errors for a specific field
106    #[must_use]
107    pub fn for_field(&self, field: &str) -> &[FieldError] {
108        self.errors.get(field).map_or(&[], Vec::as_slice)
109    }
110
111    /// Get all field names that have errors
112    #[must_use]
113    pub fn fields_with_errors(&self) -> Vec<&str> {
114        self.errors.keys().map(String::as_str).collect()
115    }
116
117    /// Get the total number of errors
118    #[must_use]
119    pub fn count(&self) -> usize {
120        self.errors.values().map(Vec::len).sum()
121    }
122
123    /// Clear all errors
124    pub fn clear(&mut self) {
125        self.errors.clear();
126    }
127
128    /// Merge errors from another collection
129    pub fn merge(&mut self, other: &Self) {
130        for (field, errors) in &other.errors {
131            self.errors
132                .entry(field.clone())
133                .or_default()
134                .extend(errors.iter().cloned());
135        }
136    }
137
138    /// Iterate over all errors
139    pub fn iter(&self) -> impl Iterator<Item = (&str, &[FieldError])> {
140        self.errors
141            .iter()
142            .map(|(k, v)| (k.as_str(), v.as_slice()))
143    }
144}
145
146/// Convert from validator crate's `ValidationErrors`
147///
148/// The validator crate is always available as a workspace dependency.
149impl From<validator::ValidationErrors> for ValidationErrors {
150    fn from(errors: validator::ValidationErrors) -> Self {
151        let mut result = Self::new();
152        for (field, field_errors) in errors.field_errors() {
153            for error in field_errors {
154                let message = error
155                    .message
156                    .as_ref()
157                    .map_or_else(|| error.code.to_string(), ToString::to_string);
158                result.add_with_code(field.to_string(), message, error.code.to_string());
159            }
160        }
161        result
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_field_error() {
171        let error = FieldError::new("is required");
172        assert_eq!(error.message, "is required");
173        assert!(error.code.is_none());
174    }
175
176    #[test]
177    fn test_field_error_with_code() {
178        let error = FieldError::with_code("is required", "required");
179        assert_eq!(error.message, "is required");
180        assert_eq!(error.code.as_deref(), Some("required"));
181    }
182
183    #[test]
184    fn test_validation_errors_new() {
185        let errors = ValidationErrors::new();
186        assert!(!errors.has_errors());
187        assert_eq!(errors.count(), 0);
188    }
189
190    #[test]
191    fn test_validation_errors_add() {
192        let mut errors = ValidationErrors::new();
193        errors.add("email", "is required");
194        errors.add("email", "is invalid");
195
196        assert!(errors.has_errors());
197        assert!(errors.has_field_error("email"));
198        assert!(!errors.has_field_error("password"));
199        assert_eq!(errors.for_field("email").len(), 2);
200        assert_eq!(errors.count(), 2);
201    }
202
203    #[test]
204    fn test_validation_errors_merge() {
205        let mut errors1 = ValidationErrors::new();
206        errors1.add("email", "is required");
207
208        let mut errors2 = ValidationErrors::new();
209        errors2.add("password", "too short");
210        errors2.add("email", "is invalid");
211
212        errors1.merge(&errors2);
213
214        assert_eq!(errors1.for_field("email").len(), 2);
215        assert_eq!(errors1.for_field("password").len(), 1);
216        assert_eq!(errors1.count(), 3);
217    }
218
219    #[test]
220    fn test_validation_errors_clear() {
221        let mut errors = ValidationErrors::new();
222        errors.add("email", "is required");
223        assert!(errors.has_errors());
224
225        errors.clear();
226        assert!(!errors.has_errors());
227    }
228
229    #[test]
230    fn test_fields_with_errors() {
231        let mut errors = ValidationErrors::new();
232        errors.add("email", "is required");
233        errors.add("password", "too short");
234
235        let fields = errors.fields_with_errors();
236        assert_eq!(fields.len(), 2);
237        assert!(fields.contains(&"email"));
238        assert!(fields.contains(&"password"));
239    }
240}