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    /// 1-based EDIFACT position of the offending data element (or
118    /// composite) within the segment. Counts the segment identifier as
119    /// position 1, so the first element after the tag is position 2.
120    /// Maps to CONTRL UCD `0098`.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub field_element_position: Option<u32>,
123
124    /// 1-based position of the offending component within its composite
125    /// data element. Maps to CONTRL UCD `0104`. `None` when the issue is
126    /// at the simple-element level.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub field_component_position: Option<u32>,
129}
130
131impl ValidationIssue {
132    /// Create a new validation issue with the required fields.
133    pub fn new(
134        severity: Severity,
135        category: ValidationCategory,
136        code: impl Into<String>,
137        message: impl Into<String>,
138    ) -> Self {
139        Self {
140            severity,
141            category,
142            code: code.into(),
143            message: message.into(),
144            segment_position: None,
145            field_path: None,
146            rule: None,
147            actual_value: None,
148            expected_value: None,
149            bo4e_path: None,
150            instance_index: None,
151            field_element_position: None,
152            field_component_position: None,
153        }
154    }
155
156    /// Builder: set the 1-based field positions used by CONTRL UCD `S011`.
157    ///
158    /// `element_pos` counts the segment identifier as position 1 (so the
159    /// first element after the tag is position 2). `component_pos` is the
160    /// 1-based position within a composite, or `None` for simple elements.
161    pub fn with_field_position(
162        mut self,
163        element_pos: u32,
164        component_pos: Option<u32>,
165    ) -> Self {
166        self.field_element_position = Some(element_pos);
167        self.field_component_position = component_pos;
168        self
169    }
170
171    /// Builder: set the segment position.
172    pub fn with_position(mut self, position: impl Into<SegmentPosition>) -> Self {
173        self.segment_position = Some(position.into());
174        self
175    }
176
177    /// Builder: set the field path.
178    pub fn with_field_path(mut self, path: impl Into<String>) -> Self {
179        self.field_path = Some(path.into());
180        self
181    }
182
183    /// Builder: set the AHB rule.
184    pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
185        self.rule = Some(rule.into());
186        self
187    }
188
189    /// Builder: set the actual value.
190    pub fn with_actual(mut self, value: impl Into<String>) -> Self {
191        self.actual_value = Some(value.into());
192        self
193    }
194
195    /// Builder: set the expected value.
196    pub fn with_expected(mut self, value: impl Into<String>) -> Self {
197        self.expected_value = Some(value.into());
198        self
199    }
200
201    /// Builder: set the BO4E field path.
202    pub fn with_bo4e_path(mut self, path: impl Into<String>) -> Self {
203        self.bo4e_path = Some(path.into());
204        self
205    }
206
207    /// Builder: set the group instance index (0-based) this issue applies to.
208    pub fn with_instance_index(mut self, index: usize) -> Self {
209        self.instance_index = Some(index);
210        self
211    }
212
213    /// Returns true if this is an error-level issue.
214    pub fn is_error(&self) -> bool {
215        self.severity == Severity::Error
216    }
217
218    /// Returns true if this is a warning-level issue.
219    pub fn is_warning(&self) -> bool {
220        self.severity == Severity::Warning
221    }
222}
223
224impl std::fmt::Display for ValidationIssue {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(f, "[{}] {}: {}", self.severity, self.code, self.message)?;
227        if let Some(ref path) = self.field_path {
228            write!(f, " at {path}")?;
229        }
230        if let Some(ref pos) = self.segment_position {
231            write!(
232                f,
233                " (segment #{}, byte {})",
234                pos.segment_number, pos.byte_offset
235            )?;
236        }
237        Ok(())
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_severity_ordering() {
247        assert!(Severity::Info < Severity::Warning);
248        assert!(Severity::Warning < Severity::Error);
249    }
250
251    #[test]
252    fn test_issue_builder() {
253        let issue = ValidationIssue::new(
254            Severity::Error,
255            ValidationCategory::Ahb,
256            "AHB001",
257            "Required field missing",
258        )
259        .with_field_path("SG2/NAD/C082/3039")
260        .with_rule("Muss [182] ∧ [152]")
261        .with_position(SegmentPosition {
262            segment_number: 5,
263            byte_offset: 234,
264            message_number: 1,
265        });
266
267        assert!(issue.is_error());
268        assert!(!issue.is_warning());
269        assert_eq!(issue.code, "AHB001");
270        assert_eq!(issue.field_path.as_deref(), Some("SG2/NAD/C082/3039"));
271        assert_eq!(issue.rule.as_deref(), Some("Muss [182] ∧ [152]"));
272        assert_eq!(issue.segment_position.unwrap().segment_number, 5);
273    }
274
275    #[test]
276    fn test_issue_display() {
277        let issue = ValidationIssue::new(
278            Severity::Error,
279            ValidationCategory::Ahb,
280            "AHB001",
281            "Required field missing",
282        )
283        .with_field_path("NAD");
284
285        let display = format!("{issue}");
286        assert!(display.contains("[ERROR]"));
287        assert!(display.contains("AHB001"));
288        assert!(display.contains("Required field missing"));
289        assert!(display.contains("at NAD"));
290    }
291
292    #[test]
293    fn test_issue_serialization() {
294        let issue = ValidationIssue::new(
295            Severity::Warning,
296            ValidationCategory::Code,
297            "COD002",
298            "Code not allowed for PID",
299        );
300
301        let json = serde_json::to_string_pretty(&issue).unwrap();
302        // bo4e_path should be absent from JSON when None (skip_serializing_if)
303        assert!(!json.contains("bo4e_path"));
304        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
305        assert_eq!(deserialized.code, "COD002");
306        assert_eq!(deserialized.severity, Severity::Warning);
307        assert!(deserialized.bo4e_path.is_none());
308    }
309
310    #[test]
311    fn test_bo4e_path_builder_and_serialization() {
312        let issue = ValidationIssue::new(
313            Severity::Error,
314            ValidationCategory::Ahb,
315            "AHB001",
316            "Required field missing",
317        )
318        .with_field_path("SG4/SG5/LOC/C517/3225")
319        .with_bo4e_path("stammdaten.Marktlokation.marktlokationsId");
320
321        assert_eq!(
322            issue.bo4e_path.as_deref(),
323            Some("stammdaten.Marktlokation.marktlokationsId")
324        );
325
326        let json = serde_json::to_string_pretty(&issue).unwrap();
327        assert!(json.contains("bo4e_path"));
328        assert!(json.contains("stammdaten.Marktlokation.marktlokationsId"));
329
330        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
331        assert_eq!(
332            deserialized.bo4e_path.as_deref(),
333            Some("stammdaten.Marktlokation.marktlokationsId")
334        );
335    }
336
337    #[test]
338    fn test_category_display() {
339        assert_eq!(format!("{}", ValidationCategory::Structure), "Structure");
340        assert_eq!(format!("{}", ValidationCategory::Ahb), "AHB");
341    }
342
343    #[test]
344    fn test_position_from_edifact_primitives() {
345        let edifact_pos = edifact_primitives::SegmentPosition::new(3, 100, 1);
346        let pos: SegmentPosition = edifact_pos.into();
347        assert_eq!(pos.segment_number, 3);
348        assert_eq!(pos.byte_offset, 100);
349        assert_eq!(pos.message_number, 1);
350    }
351
352    #[test]
353    fn issue_instance_index_round_trip() {
354        let issue = ValidationIssue::new(
355            Severity::Error,
356            ValidationCategory::Ahb,
357            "AHB001",
358            "x",
359        )
360        .with_instance_index(3);
361        assert_eq!(issue.instance_index, Some(3));
362    }
363
364    #[test]
365    fn issue_instance_index_defaults_to_none() {
366        let issue = ValidationIssue::new(
367            Severity::Error,
368            ValidationCategory::Ahb,
369            "AHB001",
370            "x",
371        );
372        assert_eq!(issue.instance_index, None);
373    }
374}