Skip to main content

automapper_validation/validator/
issue.rs

1//! Validation issue types.
2
3use serde::{Deserialize, Serialize};
4
5/// Severity level of a validation issue.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
7pub enum Severity {
8    /// Informational message, not a problem.
9    Info,
10    /// Warning that may indicate a problem but does not fail validation.
11    Warning,
12    /// Error that causes validation to fail.
13    Error,
14}
15
16impl std::fmt::Display for Severity {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Severity::Info => write!(f, "INFO"),
20            Severity::Warning => write!(f, "WARN"),
21            Severity::Error => write!(f, "ERROR"),
22        }
23    }
24}
25
26/// Category of validation issue.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum ValidationCategory {
29    /// Structural issues: missing segments, wrong order, MaxRep exceeded.
30    Structure,
31    /// Format issues: invalid data format (an..35, n13, dates).
32    Format,
33    /// Code issues: invalid code value not in allowed list.
34    Code,
35    /// AHB issues: PID-specific condition rule violations.
36    Ahb,
37}
38
39impl std::fmt::Display for ValidationCategory {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            ValidationCategory::Structure => write!(f, "Structure"),
43            ValidationCategory::Format => write!(f, "Format"),
44            ValidationCategory::Code => write!(f, "Code"),
45            ValidationCategory::Ahb => write!(f, "AHB"),
46        }
47    }
48}
49
50/// Serializable segment position for validation reports.
51///
52/// Mirrors `edifact_primitives::SegmentPosition` but with serde support,
53/// since the edifact-primitives crate is intentionally zero-dependency.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub struct SegmentPosition {
56    /// 1-based segment number within the interchange.
57    pub segment_number: u32,
58    /// Byte offset from the start of the input.
59    pub byte_offset: usize,
60    /// 1-based message number within the interchange.
61    pub message_number: u32,
62}
63
64impl From<edifact_primitives::SegmentPosition> for SegmentPosition {
65    fn from(pos: edifact_primitives::SegmentPosition) -> Self {
66        Self {
67            segment_number: pos.segment_number,
68            byte_offset: pos.byte_offset,
69            message_number: pos.message_number,
70        }
71    }
72}
73
74/// A single validation issue found in an EDIFACT message.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ValidationIssue {
77    /// Severity level of this issue.
78    pub severity: Severity,
79
80    /// Category of this issue.
81    pub category: ValidationCategory,
82
83    /// Machine-readable error code (e.g., "STR001", "AHB003").
84    pub code: String,
85
86    /// Human-readable error message.
87    pub message: String,
88
89    /// Position in the EDIFACT message where the issue was found.
90    pub segment_position: Option<SegmentPosition>,
91
92    /// Field path within the segment (e.g., "SG2/NAD/C082/3039").
93    pub field_path: Option<String>,
94
95    /// The AHB rule that triggered this issue (e.g., "Muss [182] ∧ [152]").
96    pub rule: Option<String>,
97
98    /// The actual value found (if applicable).
99    pub actual_value: Option<String>,
100
101    /// The expected value (if applicable).
102    pub expected_value: Option<String>,
103
104    /// BO4E field path (e.g., "stammdaten.Marktlokation.marktlokationsId").
105    /// Set when validation is triggered from BO4E input and errors can be
106    /// traced back to the source BO4E structure.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub bo4e_path: Option<String>,
109
110    /// Index of the group instance this issue applies to (0-based) when
111    /// the issue originated inside a group repetition. `None` for issues
112    /// that aren't scoped to a specific instance (e.g., top-level root
113    /// fields, unmatched rules, flat-segment checks).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub instance_index: Option<usize>,
116}
117
118impl ValidationIssue {
119    /// Create a new validation issue with the required fields.
120    pub fn new(
121        severity: Severity,
122        category: ValidationCategory,
123        code: impl Into<String>,
124        message: impl Into<String>,
125    ) -> Self {
126        Self {
127            severity,
128            category,
129            code: code.into(),
130            message: message.into(),
131            segment_position: None,
132            field_path: None,
133            rule: None,
134            actual_value: None,
135            expected_value: None,
136            bo4e_path: None,
137            instance_index: None,
138        }
139    }
140
141    /// Builder: set the segment position.
142    pub fn with_position(mut self, position: impl Into<SegmentPosition>) -> Self {
143        self.segment_position = Some(position.into());
144        self
145    }
146
147    /// Builder: set the field path.
148    pub fn with_field_path(mut self, path: impl Into<String>) -> Self {
149        self.field_path = Some(path.into());
150        self
151    }
152
153    /// Builder: set the AHB rule.
154    pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
155        self.rule = Some(rule.into());
156        self
157    }
158
159    /// Builder: set the actual value.
160    pub fn with_actual(mut self, value: impl Into<String>) -> Self {
161        self.actual_value = Some(value.into());
162        self
163    }
164
165    /// Builder: set the expected value.
166    pub fn with_expected(mut self, value: impl Into<String>) -> Self {
167        self.expected_value = Some(value.into());
168        self
169    }
170
171    /// Builder: set the BO4E field path.
172    pub fn with_bo4e_path(mut self, path: impl Into<String>) -> Self {
173        self.bo4e_path = Some(path.into());
174        self
175    }
176
177    /// Builder: set the group instance index (0-based) this issue applies to.
178    pub fn with_instance_index(mut self, index: usize) -> Self {
179        self.instance_index = Some(index);
180        self
181    }
182
183    /// Returns true if this is an error-level issue.
184    pub fn is_error(&self) -> bool {
185        self.severity == Severity::Error
186    }
187
188    /// Returns true if this is a warning-level issue.
189    pub fn is_warning(&self) -> bool {
190        self.severity == Severity::Warning
191    }
192}
193
194impl std::fmt::Display for ValidationIssue {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        write!(f, "[{}] {}: {}", self.severity, self.code, self.message)?;
197        if let Some(ref path) = self.field_path {
198            write!(f, " at {path}")?;
199        }
200        if let Some(ref pos) = self.segment_position {
201            write!(
202                f,
203                " (segment #{}, byte {})",
204                pos.segment_number, pos.byte_offset
205            )?;
206        }
207        Ok(())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_severity_ordering() {
217        assert!(Severity::Info < Severity::Warning);
218        assert!(Severity::Warning < Severity::Error);
219    }
220
221    #[test]
222    fn test_issue_builder() {
223        let issue = ValidationIssue::new(
224            Severity::Error,
225            ValidationCategory::Ahb,
226            "AHB001",
227            "Required field missing",
228        )
229        .with_field_path("SG2/NAD/C082/3039")
230        .with_rule("Muss [182] ∧ [152]")
231        .with_position(SegmentPosition {
232            segment_number: 5,
233            byte_offset: 234,
234            message_number: 1,
235        });
236
237        assert!(issue.is_error());
238        assert!(!issue.is_warning());
239        assert_eq!(issue.code, "AHB001");
240        assert_eq!(issue.field_path.as_deref(), Some("SG2/NAD/C082/3039"));
241        assert_eq!(issue.rule.as_deref(), Some("Muss [182] ∧ [152]"));
242        assert_eq!(issue.segment_position.unwrap().segment_number, 5);
243    }
244
245    #[test]
246    fn test_issue_display() {
247        let issue = ValidationIssue::new(
248            Severity::Error,
249            ValidationCategory::Ahb,
250            "AHB001",
251            "Required field missing",
252        )
253        .with_field_path("NAD");
254
255        let display = format!("{issue}");
256        assert!(display.contains("[ERROR]"));
257        assert!(display.contains("AHB001"));
258        assert!(display.contains("Required field missing"));
259        assert!(display.contains("at NAD"));
260    }
261
262    #[test]
263    fn test_issue_serialization() {
264        let issue = ValidationIssue::new(
265            Severity::Warning,
266            ValidationCategory::Code,
267            "COD002",
268            "Code not allowed for PID",
269        );
270
271        let json = serde_json::to_string_pretty(&issue).unwrap();
272        // bo4e_path should be absent from JSON when None (skip_serializing_if)
273        assert!(!json.contains("bo4e_path"));
274        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
275        assert_eq!(deserialized.code, "COD002");
276        assert_eq!(deserialized.severity, Severity::Warning);
277        assert!(deserialized.bo4e_path.is_none());
278    }
279
280    #[test]
281    fn test_bo4e_path_builder_and_serialization() {
282        let issue = ValidationIssue::new(
283            Severity::Error,
284            ValidationCategory::Ahb,
285            "AHB001",
286            "Required field missing",
287        )
288        .with_field_path("SG4/SG5/LOC/C517/3225")
289        .with_bo4e_path("stammdaten.Marktlokation.marktlokationsId");
290
291        assert_eq!(
292            issue.bo4e_path.as_deref(),
293            Some("stammdaten.Marktlokation.marktlokationsId")
294        );
295
296        let json = serde_json::to_string_pretty(&issue).unwrap();
297        assert!(json.contains("bo4e_path"));
298        assert!(json.contains("stammdaten.Marktlokation.marktlokationsId"));
299
300        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
301        assert_eq!(
302            deserialized.bo4e_path.as_deref(),
303            Some("stammdaten.Marktlokation.marktlokationsId")
304        );
305    }
306
307    #[test]
308    fn test_category_display() {
309        assert_eq!(format!("{}", ValidationCategory::Structure), "Structure");
310        assert_eq!(format!("{}", ValidationCategory::Ahb), "AHB");
311    }
312
313    #[test]
314    fn test_position_from_edifact_primitives() {
315        let edifact_pos = edifact_primitives::SegmentPosition::new(3, 100, 1);
316        let pos: SegmentPosition = edifact_pos.into();
317        assert_eq!(pos.segment_number, 3);
318        assert_eq!(pos.byte_offset, 100);
319        assert_eq!(pos.message_number, 1);
320    }
321
322    #[test]
323    fn issue_instance_index_round_trip() {
324        let issue = ValidationIssue::new(
325            Severity::Error,
326            ValidationCategory::Ahb,
327            "AHB001",
328            "x",
329        )
330        .with_instance_index(3);
331        assert_eq!(issue.instance_index, Some(3));
332    }
333
334    #[test]
335    fn issue_instance_index_defaults_to_none() {
336        let issue = ValidationIssue::new(
337            Severity::Error,
338            ValidationCategory::Ahb,
339            "AHB001",
340            "x",
341        );
342        assert_eq!(issue.instance_index, None);
343    }
344}