Skip to main content

automapper_validation/validator/
report.rs

1//! Validation report aggregating all issues from a validation run.
2
3use serde::{Deserialize, Serialize};
4
5use super::issue::{Severity, ValidationCategory, ValidationIssue};
6use super::level::ValidationLevel;
7
8/// Complete validation report for an EDIFACT message.
9///
10/// Contains all issues found during validation, with convenience methods
11/// for filtering by severity and checking overall validity.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ValidationReport {
14    /// The detected EDIFACT message type (e.g., "UTILMD", "ORDERS").
15    pub message_type: String,
16
17    /// The detected Pruefidentifikator (e.g., "11001", "55001").
18    pub pruefidentifikator: Option<String>,
19
20    /// The detected format version (e.g., "FV2510").
21    pub format_version: Option<String>,
22
23    /// The validation level that was used.
24    pub level: ValidationLevel,
25
26    /// All validation issues found.
27    pub issues: Vec<ValidationIssue>,
28}
29
30impl ValidationReport {
31    /// Create a new empty validation report.
32    pub fn new(message_type: impl Into<String>, level: ValidationLevel) -> Self {
33        Self {
34            message_type: message_type.into(),
35            pruefidentifikator: None,
36            format_version: None,
37            level,
38            issues: Vec::new(),
39        }
40    }
41
42    /// Builder: set the Pruefidentifikator.
43    pub fn with_pruefidentifikator(mut self, pid: impl Into<String>) -> Self {
44        self.pruefidentifikator = Some(pid.into());
45        self
46    }
47
48    /// Builder: set the format version.
49    pub fn with_format_version(mut self, fv: impl Into<String>) -> Self {
50        self.format_version = Some(fv.into());
51        self
52    }
53
54    /// Add a validation issue.
55    pub fn add_issue(&mut self, issue: ValidationIssue) {
56        self.issues.push(issue);
57    }
58
59    /// Add multiple validation issues.
60    pub fn add_issues(&mut self, issues: impl IntoIterator<Item = ValidationIssue>) {
61        self.issues.extend(issues);
62    }
63
64    /// Returns `true` if there are no error-level issues.
65    pub fn is_valid(&self) -> bool {
66        !self.issues.iter().any(|i| i.severity == Severity::Error)
67    }
68
69    /// Returns the number of error-level issues.
70    pub fn error_count(&self) -> usize {
71        self.issues
72            .iter()
73            .filter(|i| i.severity == Severity::Error)
74            .count()
75    }
76
77    /// Returns the number of warning-level issues.
78    pub fn warning_count(&self) -> usize {
79        self.issues
80            .iter()
81            .filter(|i| i.severity == Severity::Warning)
82            .count()
83    }
84
85    /// Returns all error-level issues.
86    pub fn errors(&self) -> impl Iterator<Item = &ValidationIssue> {
87        self.issues.iter().filter(|i| i.severity == Severity::Error)
88    }
89
90    /// Returns all warning-level issues.
91    pub fn warnings(&self) -> impl Iterator<Item = &ValidationIssue> {
92        self.issues
93            .iter()
94            .filter(|i| i.severity == Severity::Warning)
95    }
96
97    /// Returns all info-level issues.
98    pub fn infos(&self) -> impl Iterator<Item = &ValidationIssue> {
99        self.issues.iter().filter(|i| i.severity == Severity::Info)
100    }
101
102    /// Returns issues filtered by category.
103    pub fn by_category(
104        &self,
105        category: ValidationCategory,
106    ) -> impl Iterator<Item = &ValidationIssue> {
107        self.issues.iter().filter(move |i| i.category() == category)
108    }
109
110    /// Returns the total number of issues.
111    pub fn total_issues(&self) -> usize {
112        self.issues.len()
113    }
114
115    /// Enrich all issues that have a `field_path` by resolving BO4E paths.
116    ///
117    /// The `resolver` closure maps an EDIFACT field path (e.g., "SG4/SG5/LOC/C517/3225")
118    /// and an optional hint (e.g., codes from the validation message) to a BO4E field path
119    /// (e.g., "stammdaten.Marktlokation.marktlokationsId").
120    /// Issues without a `field_path` or where the resolver returns `None` are left unchanged.
121    pub fn enrich_bo4e_paths(&mut self, resolver: impl Fn(&str, Option<&str>) -> Option<String>) {
122        for issue in &mut self.issues {
123            if let Some(ref edifact_path) = issue.field_path {
124                // Extract a hint from the expected_value or the message for disambiguation.
125                // For missing qualifier fields, expected_value may contain the code (e.g., "93").
126                let hint = issue.expected_value.as_deref().or(issue.rule.as_deref());
127                issue.bo4e_path = resolver(edifact_path, hint);
128            }
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::IssueKind;
137
138    /// Category Ahb. The specific kind doesn't matter to these tests — only
139    /// that it derives an Ahb category.
140    fn make_error() -> ValidationIssue {
141        ValidationIssue::new(
142            Severity::Error,
143            IssueKind::MissingRequiredField {
144                field_name: "test".into(),
145            },
146        )
147    }
148
149    /// Category Structure.
150    fn make_warning() -> ValidationIssue {
151        ValidationIssue::new(
152            Severity::Warning,
153            IssueKind::UntSegmentCountMismatch {
154                declared: 1,
155                actual: 2,
156            },
157        )
158    }
159
160    /// Category Code.
161    fn make_info() -> ValidationIssue {
162        ValidationIssue::new(
163            Severity::Info,
164            IssueKind::CodeNotAllowedForPid {
165                value: "X".into(),
166                allowed: vec![],
167            },
168        )
169    }
170
171    #[test]
172    fn test_empty_report_is_valid() {
173        let report = ValidationReport::new("UTILMD", ValidationLevel::Full);
174        assert!(report.is_valid());
175        assert_eq!(report.error_count(), 0);
176        assert_eq!(report.warning_count(), 0);
177        assert_eq!(report.total_issues(), 0);
178    }
179
180    #[test]
181    fn test_report_with_errors_is_invalid() {
182        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
183        report.add_issue(make_error());
184
185        assert!(!report.is_valid());
186        assert_eq!(report.error_count(), 1);
187    }
188
189    #[test]
190    fn test_report_with_only_warnings_is_valid() {
191        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
192        report.add_issue(make_warning());
193
194        assert!(report.is_valid());
195        assert_eq!(report.warning_count(), 1);
196        assert_eq!(report.error_count(), 0);
197    }
198
199    #[test]
200    fn test_report_mixed_issues() {
201        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full)
202            .with_pruefidentifikator("11001")
203            .with_format_version("FV2510");
204
205        report.add_issue(make_error());
206        report.add_issue(make_error());
207        report.add_issue(make_warning());
208        report.add_issue(make_info());
209
210        assert!(!report.is_valid());
211        assert_eq!(report.error_count(), 2);
212        assert_eq!(report.warning_count(), 1);
213        assert_eq!(report.total_issues(), 4);
214        assert_eq!(report.errors().count(), 2);
215        assert_eq!(report.warnings().count(), 1);
216        assert_eq!(report.infos().count(), 1);
217    }
218
219    #[test]
220    fn test_report_by_category() {
221        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
222        report.add_issue(make_error());
223        report.add_issue(make_warning());
224
225        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 1);
226        assert_eq!(report.by_category(ValidationCategory::Structure).count(), 1);
227        assert_eq!(report.by_category(ValidationCategory::Format).count(), 0);
228    }
229
230    #[test]
231    fn test_report_add_issues() {
232        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
233        let issues = vec![make_error(), make_warning()];
234        report.add_issues(issues);
235
236        assert_eq!(report.total_issues(), 2);
237    }
238
239    #[test]
240    fn test_enrich_bo4e_paths() {
241        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
242        report.add_issue(make_error().with_field_path("SG4/SG5/LOC/C517/3225"));
243        report.add_issue(make_warning().with_field_path("SG2/NAD/3035"));
244        // Issue without field_path should be left alone
245        report.add_issue(make_error());
246
247        report.enrich_bo4e_paths(|path, _hint| match path {
248            "SG4/SG5/LOC/C517/3225" => Some("stammdaten.Marktlokation.marktlokationsId".into()),
249            "SG2/NAD/3035" => Some("stammdaten.Marktteilnehmer".into()),
250            _ => None,
251        });
252
253        assert_eq!(
254            report.issues[0].bo4e_path.as_deref(),
255            Some("stammdaten.Marktlokation.marktlokationsId")
256        );
257        assert_eq!(
258            report.issues[1].bo4e_path.as_deref(),
259            Some("stammdaten.Marktteilnehmer")
260        );
261        // No field_path → no bo4e_path
262        assert!(report.issues[2].bo4e_path.is_none());
263    }
264
265    #[test]
266    fn test_report_serialization() {
267        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Conditions)
268            .with_pruefidentifikator("11001")
269            .with_format_version("FV2510");
270        report.add_issue(make_error());
271
272        let json = serde_json::to_string_pretty(&report).unwrap();
273        assert!(json.contains("UTILMD"));
274        assert!(json.contains("11001"));
275        // The type has no `code` field any more — it's derived from `kind`,
276        // which serializes as its own tagged struct.
277        assert!(json.contains("missingRequiredField"));
278
279        let deserialized: ValidationReport = serde_json::from_str(&json).unwrap();
280        assert_eq!(deserialized.message_type, "UTILMD");
281        assert_eq!(deserialized.pruefidentifikator.as_deref(), Some("11001"));
282        assert_eq!(deserialized.total_issues(), 1);
283        assert_eq!(deserialized.issues[0].code(), "AHB001");
284    }
285}