Skip to main content

automapper_validation/validator/
issue.rs

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