Skip to main content

a2ui_base/validate/
error.rs

1//! Validation error types: structured codes + an aggregating report.
2//!
3//! `A2uiError::Validation(String)` is a single-string leaf and cannot carry the
4//! structured (code + path + component_id) multi-error shape that the Python
5//! validator collects. `ValidationReport` aggregates `Vec<ValidationError>` and
6//! bridges back to the existing error flow via `From<ValidationReport>`.
7
8/// Machine-readable classification of a validation problem.
9///
10/// Mirrors the distinct failure modes the Python validator distinguishes:
11/// integrity (`DuplicateId`, `MissingRoot`, `DanglingReference`), topology
12/// (`SelfReference`, `CircularReference`, `OrphanComponent`), and
13/// recursion/path (`GlobalDepthExceeded`, `FuncCallDepthExceeded`,
14/// `InvalidPathSyntax`).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ValidationErrorCode {
17    DuplicateId,
18    MissingRoot,
19    DanglingReference,
20    SelfReference,
21    CircularReference,
22    OrphanComponent,
23    GlobalDepthExceeded,
24    FuncCallDepthExceeded,
25    InvalidPathSyntax,
26}
27
28/// A single validation finding.
29#[derive(Debug, Clone)]
30pub struct ValidationError {
31    pub code: ValidationErrorCode,
32    pub message: String,
33    pub component_id: Option<String>,
34    pub path: Option<String>,
35}
36
37impl ValidationError {
38    // -- convenience constructors (keep call sites clean) --
39
40    pub fn duplicate_id(id: &str) -> Self {
41        Self {
42            code: ValidationErrorCode::DuplicateId,
43            message: format!("Duplicate component ID: {id}"),
44            component_id: Some(id.to_string()),
45            path: None,
46        }
47    }
48
49    pub fn missing_root(root_id: &str) -> Self {
50        Self {
51            code: ValidationErrorCode::MissingRoot,
52            message: format!("Missing root component: No component has id='{root_id}'"),
53            component_id: Some(root_id.to_string()),
54            path: None,
55        }
56    }
57
58    pub fn dangling(component_id: &str, ref_id: &str, field: &str) -> Self {
59        Self {
60            code: ValidationErrorCode::DanglingReference,
61            message: format!(
62                "Component '{component_id}' references non-existent component '{ref_id}' in field '{field}'"
63            ),
64            component_id: Some(component_id.to_string()),
65            path: Some(field.to_string()),
66        }
67    }
68
69    pub fn self_ref(component_id: &str, field: &str) -> Self {
70        Self {
71            code: ValidationErrorCode::SelfReference,
72            message: format!(
73                "Self-reference detected: Component '{component_id}' references itself in field '{field}'"
74            ),
75            component_id: Some(component_id.to_string()),
76            path: Some(field.to_string()),
77        }
78    }
79
80    pub fn circular(component_id: &str) -> Self {
81        Self {
82            code: ValidationErrorCode::CircularReference,
83            message: format!(
84                "Circular reference detected involving component '{component_id}'"
85            ),
86            component_id: Some(component_id.to_string()),
87            path: None,
88        }
89    }
90
91    pub fn orphan(component_id: &str, root_id: &str) -> Self {
92        Self {
93            code: ValidationErrorCode::OrphanComponent,
94            message: format!(
95                "Component '{component_id}' is not reachable from '{root_id}'"
96            ),
97            component_id: Some(component_id.to_string()),
98            path: None,
99        }
100    }
101
102    pub fn global_depth(component_id: &str) -> Self {
103        Self {
104            code: ValidationErrorCode::GlobalDepthExceeded,
105            message: format!("Global recursion limit exceeded: Depth > {}", super::integrity::MAX_GLOBAL_DEPTH),
106            component_id: Some(component_id.to_string()),
107            path: None,
108        }
109    }
110
111    pub fn func_depth() -> Self {
112        Self {
113            code: ValidationErrorCode::FuncCallDepthExceeded,
114            message: format!(
115                "Recursion limit exceeded: functionCall depth > {}",
116                super::integrity::MAX_FUNC_CALL_DEPTH
117            ),
118            component_id: None,
119            path: None,
120        }
121    }
122
123    pub fn invalid_path(path: &str) -> Self {
124        Self {
125            code: ValidationErrorCode::InvalidPathSyntax,
126            message: format!("Invalid path syntax: '{path}'"),
127            component_id: None,
128            path: Some(path.to_string()),
129        }
130    }
131}
132
133/// An aggregation of validation findings (may be empty).
134#[derive(Debug, Clone, Default)]
135pub struct ValidationReport {
136    pub errors: Vec<ValidationError>,
137}
138
139impl ValidationReport {
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    pub fn is_empty(&self) -> bool {
145        self.errors.is_empty()
146    }
147
148    pub fn push(&mut self, e: ValidationError) {
149        self.errors.push(e);
150    }
151
152    pub fn extend(&mut self, other: ValidationReport) {
153        self.errors.extend(other.errors);
154    }
155
156    /// Convert into `Ok(())` if there were no errors, otherwise `Err(self)`.
157    pub fn into_result(self) -> std::result::Result<(), Self> {
158        if self.is_empty() {
159            Ok(())
160        } else {
161            Err(self)
162        }
163    }
164
165    /// Returns the first error matching a code, for test assertions.
166    #[cfg(test)]
167    pub fn has_code(&self, code: &ValidationErrorCode) -> bool {
168        self.errors.iter().any(|e| &e.code == code)
169    }
170}
171
172impl std::fmt::Display for ValidationReport {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        let msgs: Vec<&str> = self.errors.iter().map(|e| e.message.as_str()).collect();
175        write!(f, "{}", msgs.join("\n"))
176    }
177}
178
179impl From<ValidationReport> for crate::error::A2uiError {
180    fn from(report: ValidationReport) -> Self {
181        crate::error::A2uiError::Validation(report.to_string())
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn empty_report_is_ok() {
191        let r = ValidationReport::new();
192        assert!(r.is_empty());
193        assert!(r.into_result().is_ok());
194    }
195
196    #[test]
197    fn report_with_errors_is_err() {
198        let mut r = ValidationReport::new();
199        r.push(ValidationError::duplicate_id("dup"));
200        assert!(!r.is_empty());
201        assert!(r.into_result().is_err());
202    }
203
204    #[test]
205    fn display_joins_messages_with_newline() {
206        let mut r = ValidationReport::new();
207        r.push(ValidationError::duplicate_id("a"));
208        r.push(ValidationError::missing_root("root"));
209        let s = r.to_string();
210        assert!(s.contains("Duplicate component ID: a"));
211        assert!(s.contains("Missing root component"));
212        assert_eq!(s.matches('\n').count(), 1);
213    }
214
215    #[test]
216    fn from_report_to_a2ui_error() {
217        let mut r = ValidationReport::new();
218        r.push(ValidationError::dangling("root", "ghost", "child"));
219        let err: crate::error::A2uiError = r.into();
220        match err {
221            crate::error::A2uiError::Validation(msg) => {
222                assert!(msg.contains("references non-existent component 'ghost'"));
223            }
224            other => panic!("expected Validation variant, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn extend_merges_reports() {
230        let mut a = ValidationReport::new();
231        a.push(ValidationError::duplicate_id("x"));
232        let mut b = ValidationReport::new();
233        b.push(ValidationError::missing_root("root"));
234        a.extend(b);
235        assert_eq!(a.errors.len(), 2);
236    }
237}