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 (UNB = 1).
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    /// 1-based segment number within the message, UNH = 1: the CONTRL
66    /// segment position (UCS 0096). Set by the pipeline, which knows where
67    /// the message starts; `None` for a segment outside UNH…UNT.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub message_segment_number: Option<u32>,
70}
71
72impl SegmentPosition {
73    /// The segment with interchange counter `segment_number` in the first
74    /// message, its message counter not yet known.
75    pub fn at(segment_number: u32) -> Self {
76        Self {
77            segment_number,
78            byte_offset: 0,
79            message_number: 1,
80            message_segment_number: None,
81        }
82    }
83}
84
85impl From<edifact_primitives::SegmentPosition> for SegmentPosition {
86    fn from(pos: edifact_primitives::SegmentPosition) -> Self {
87        Self {
88            segment_number: pos.segment_number,
89            byte_offset: pos.byte_offset,
90            message_number: pos.message_number,
91            message_segment_number: None,
92        }
93    }
94}
95
96/// A single validation issue found in an EDIFACT message.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ValidationIssue {
99    /// Severity level of this issue.
100    pub severity: Severity,
101
102    /// Machine-readable description of the problem. `code()` and `category()`
103    /// are derived from it, so they can never disagree with the kind.
104    pub kind: IssueKind,
105
106    /// Position in the EDIFACT message where the issue was found.
107    pub segment_position: Option<SegmentPosition>,
108
109    /// Field path within the segment (e.g., "SG2/NAD/C082/3039").
110    pub field_path: Option<String>,
111
112    /// The AHB rule that triggered this issue (e.g., "Muss [182] ∧ [152]").
113    pub rule: Option<String>,
114
115    /// The actual value found (if applicable).
116    pub actual_value: Option<String>,
117
118    /// The expected value (if applicable).
119    pub expected_value: Option<String>,
120
121    /// BO4E field path (e.g., "stammdaten.Marktlokation.marktlokationsId").
122    /// Set when validation is triggered from BO4E input and errors can be
123    /// traced back to the source BO4E structure.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub bo4e_path: Option<String>,
126
127    /// Index of the group instance this issue applies to (0-based) when
128    /// the issue originated inside a group repetition. `None` for issues
129    /// that aren't scoped to a specific instance (e.g., top-level root
130    /// fields, unmatched rules, flat-segment checks).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub instance_index: Option<usize>,
133
134    /// 1-based EDIFACT position of the offending data element (or
135    /// composite) within the segment. Counts the segment identifier as
136    /// position 1, so the first element after the tag is position 2.
137    /// Maps to CONTRL UCD `0098`.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub field_element_position: Option<u32>,
140
141    /// 1-based position of the offending component within its composite
142    /// data element. Maps to CONTRL UCD `0104`. `None` when the issue is
143    /// at the simple-element level.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub field_component_position: Option<u32>,
146
147    /// MIG `Number` of the segment slot the issue concerns (the AHB rule's),
148    /// when known: it names the segment as the message description does
149    /// (the APERAK's Ortsangabe, FTX+Z02).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub mig_number: Option<String>,
152
153    /// 0-based index of the transaction (UTILMD SG4) the issue lies in, when
154    /// the message type has transactions and the issue is in one; `None` for
155    /// the message header and for message types without transactions.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub transaction_index: Option<usize>,
158}
159
160impl ValidationIssue {
161    /// Create a validation issue. Category and code are derived from `kind`.
162    pub fn new(severity: Severity, kind: IssueKind) -> Self {
163        Self {
164            severity,
165            kind,
166            segment_position: None,
167            field_path: None,
168            rule: None,
169            actual_value: None,
170            expected_value: None,
171            bo4e_path: None,
172            instance_index: None,
173            field_element_position: None,
174            field_component_position: None,
175            mig_number: None,
176            transaction_index: None,
177        }
178    }
179
180    /// Builder: set the MIG `Number` of the segment slot (no-op for `None`).
181    pub fn with_mig_number(mut self, number: Option<&str>) -> Self {
182        if let Some(number) = number {
183            self.mig_number = Some(number.to_string());
184        }
185        self
186    }
187
188    /// The stable machine identifier, e.g. `"AHB001"`.
189    pub fn code(&self) -> &'static str {
190        self.kind.code()
191    }
192
193    /// The issue's category, derived from its kind.
194    pub fn category(&self) -> ValidationCategory {
195        self.kind.category()
196    }
197
198    /// Builder: set the 1-based field positions used by CONTRL UCD `S011`.
199    ///
200    /// `element_pos` counts the segment identifier as position 1 (so the
201    /// first element after the tag is position 2). `component_pos` is the
202    /// 1-based position within a composite, or `None` for simple elements.
203    pub fn with_field_position(mut self, element_pos: u32, component_pos: Option<u32>) -> Self {
204        self.field_element_position = Some(element_pos);
205        self.field_component_position = component_pos;
206        self
207    }
208
209    /// Builder: set the segment position.
210    pub fn with_position(mut self, position: impl Into<SegmentPosition>) -> Self {
211        self.segment_position = Some(position.into());
212        self
213    }
214
215    /// Builder: set the field path.
216    pub fn with_field_path(mut self, path: impl Into<String>) -> Self {
217        self.field_path = Some(path.into());
218        self
219    }
220
221    /// Builder: set the AHB rule.
222    pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
223        self.rule = Some(rule.into());
224        self
225    }
226
227    /// Builder: set the actual value.
228    pub fn with_actual(mut self, value: impl Into<String>) -> Self {
229        self.actual_value = Some(value.into());
230        self
231    }
232
233    /// Builder: set the expected value.
234    pub fn with_expected(mut self, value: impl Into<String>) -> Self {
235        self.expected_value = Some(value.into());
236        self
237    }
238
239    /// Builder: set the BO4E field path.
240    pub fn with_bo4e_path(mut self, path: impl Into<String>) -> Self {
241        self.bo4e_path = Some(path.into());
242        self
243    }
244
245    /// Builder: set the group instance index (0-based) this issue applies to.
246    pub fn with_instance_index(mut self, index: usize) -> Self {
247        self.instance_index = Some(index);
248        self
249    }
250
251    /// Returns true if this is an error-level issue.
252    pub fn is_error(&self) -> bool {
253        self.severity == Severity::Error
254    }
255
256    /// Returns true if this is a warning-level issue.
257    pub fn is_warning(&self) -> bool {
258        self.severity == Severity::Warning
259    }
260}
261
262impl std::fmt::Display for ValidationIssue {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        let location = self.field_path.as_deref();
265        write!(
266            f,
267            "[{}] {}: {}",
268            self.severity,
269            self.code(),
270            crate::display::TechnicalNarrator.describe(self, location)
271        )?;
272        if let Some(ref pos) = self.segment_position {
273            write!(
274                f,
275                " (segment #{}, byte {})",
276                pos.segment_number, pos.byte_offset
277            )?;
278        }
279        Ok(())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_severity_ordering() {
289        assert!(Severity::Info < Severity::Warning);
290        assert!(Severity::Warning < Severity::Error);
291    }
292
293    fn missing_field_issue() -> ValidationIssue {
294        ValidationIssue::new(
295            Severity::Error,
296            IssueKind::MissingRequiredField {
297                field_name: "Merkmal, Code".into(),
298            },
299        )
300    }
301
302    #[test]
303    fn test_issue_builder() {
304        let issue = missing_field_issue()
305            .with_field_path("SG2/NAD/C082/3039")
306            .with_rule("Muss [182] ∧ [152]")
307            .with_position(SegmentPosition {
308                byte_offset: 234,
309                ..SegmentPosition::at(5)
310            });
311
312        assert!(issue.is_error());
313        assert!(!issue.is_warning());
314        assert_eq!(issue.code(), "AHB001");
315        assert_eq!(issue.field_path.as_deref(), Some("SG2/NAD/C082/3039"));
316        assert_eq!(issue.rule.as_deref(), Some("Muss [182] ∧ [152]"));
317        assert_eq!(issue.segment_position.unwrap().segment_number, 5);
318    }
319
320    #[test]
321    fn test_issue_display() {
322        let issue = missing_field_issue().with_field_path("NAD");
323
324        let display = format!("{issue}");
325        assert!(display.contains("[ERROR]"));
326        assert!(display.contains("AHB001"));
327        assert!(display.contains("Merkmal, Code"));
328        assert!(display.contains("at NAD"));
329    }
330
331    #[test]
332    fn test_issue_serialization() {
333        let issue = ValidationIssue::new(
334            Severity::Warning,
335            IssueKind::CodeNotAllowedForPid {
336                value: "X".into(),
337                allowed: vec!["A".into()],
338            },
339        );
340
341        let json = serde_json::to_string_pretty(&issue).unwrap();
342        // bo4e_path should be absent from JSON when None (skip_serializing_if)
343        assert!(!json.contains("bo4e_path"));
344        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
345        assert_eq!(deserialized.code(), "COD002");
346        assert_eq!(deserialized.severity, Severity::Warning);
347        assert!(deserialized.bo4e_path.is_none());
348    }
349
350    #[test]
351    fn test_bo4e_path_builder_and_serialization() {
352        let issue = missing_field_issue()
353            .with_field_path("SG4/SG5/LOC/C517/3225")
354            .with_bo4e_path("stammdaten.Marktlokation.marktlokationsId");
355
356        assert_eq!(
357            issue.bo4e_path.as_deref(),
358            Some("stammdaten.Marktlokation.marktlokationsId")
359        );
360
361        let json = serde_json::to_string_pretty(&issue).unwrap();
362        assert!(json.contains("bo4e_path"));
363        assert!(json.contains("stammdaten.Marktlokation.marktlokationsId"));
364
365        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
366        assert_eq!(
367            deserialized.bo4e_path.as_deref(),
368            Some("stammdaten.Marktlokation.marktlokationsId")
369        );
370    }
371
372    #[test]
373    fn test_category_display() {
374        assert_eq!(format!("{}", ValidationCategory::Structure), "Structure");
375        assert_eq!(format!("{}", ValidationCategory::Ahb), "AHB");
376    }
377
378    #[test]
379    fn test_position_from_edifact_primitives() {
380        let edifact_pos = edifact_primitives::SegmentPosition::new(3, 100, 1);
381        let pos: SegmentPosition = edifact_pos.into();
382        assert_eq!(pos.segment_number, 3);
383        assert_eq!(pos.byte_offset, 100);
384        assert_eq!(pos.message_number, 1);
385    }
386
387    #[test]
388    fn issue_instance_index_round_trip() {
389        let issue = missing_field_issue().with_instance_index(3);
390        assert_eq!(issue.instance_index, Some(3));
391    }
392
393    #[test]
394    fn issue_instance_index_defaults_to_none() {
395        let issue = missing_field_issue();
396        assert_eq!(issue.instance_index, None);
397    }
398
399    #[test]
400    fn code_and_category_are_derived_and_display_has_no_duplicate_path() {
401        let issue = ValidationIssue::new(
402            Severity::Error,
403            IssueKind::MissingRequiredField {
404                field_name: "Merkmal, Code".into(),
405            },
406        )
407        .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
408
409        assert_eq!(issue.code(), "AHB001");
410        assert_eq!(issue.category(), ValidationCategory::Ahb);
411        // The old Display appended " at {path}" to a message that already contained
412        // the path, printing it twice. It must now appear exactly once.
413        let shown = issue.to_string();
414        assert_eq!(
415            shown.matches("SG4/SG8/SG10/CCI/C240/7037").count(),
416            1,
417            "{shown}"
418        );
419    }
420}