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
111impl ValidationIssue {
112    /// Create a new validation issue with the required fields.
113    pub fn new(
114        severity: Severity,
115        category: ValidationCategory,
116        code: impl Into<String>,
117        message: impl Into<String>,
118    ) -> Self {
119        Self {
120            severity,
121            category,
122            code: code.into(),
123            message: message.into(),
124            segment_position: None,
125            field_path: None,
126            rule: None,
127            actual_value: None,
128            expected_value: None,
129            bo4e_path: None,
130        }
131    }
132
133    /// Builder: set the segment position.
134    pub fn with_position(mut self, position: impl Into<SegmentPosition>) -> Self {
135        self.segment_position = Some(position.into());
136        self
137    }
138
139    /// Builder: set the field path.
140    pub fn with_field_path(mut self, path: impl Into<String>) -> Self {
141        self.field_path = Some(path.into());
142        self
143    }
144
145    /// Builder: set the AHB rule.
146    pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
147        self.rule = Some(rule.into());
148        self
149    }
150
151    /// Builder: set the actual value.
152    pub fn with_actual(mut self, value: impl Into<String>) -> Self {
153        self.actual_value = Some(value.into());
154        self
155    }
156
157    /// Builder: set the expected value.
158    pub fn with_expected(mut self, value: impl Into<String>) -> Self {
159        self.expected_value = Some(value.into());
160        self
161    }
162
163    /// Builder: set the BO4E field path.
164    pub fn with_bo4e_path(mut self, path: impl Into<String>) -> Self {
165        self.bo4e_path = Some(path.into());
166        self
167    }
168
169    /// Returns true if this is an error-level issue.
170    pub fn is_error(&self) -> bool {
171        self.severity == Severity::Error
172    }
173
174    /// Returns true if this is a warning-level issue.
175    pub fn is_warning(&self) -> bool {
176        self.severity == Severity::Warning
177    }
178}
179
180impl std::fmt::Display for ValidationIssue {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(f, "[{}] {}: {}", self.severity, self.code, self.message)?;
183        if let Some(ref path) = self.field_path {
184            write!(f, " at {path}")?;
185        }
186        if let Some(ref pos) = self.segment_position {
187            write!(
188                f,
189                " (segment #{}, byte {})",
190                pos.segment_number, pos.byte_offset
191            )?;
192        }
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_severity_ordering() {
203        assert!(Severity::Info < Severity::Warning);
204        assert!(Severity::Warning < Severity::Error);
205    }
206
207    #[test]
208    fn test_issue_builder() {
209        let issue = ValidationIssue::new(
210            Severity::Error,
211            ValidationCategory::Ahb,
212            "AHB001",
213            "Required field missing",
214        )
215        .with_field_path("SG2/NAD/C082/3039")
216        .with_rule("Muss [182] ∧ [152]")
217        .with_position(SegmentPosition {
218            segment_number: 5,
219            byte_offset: 234,
220            message_number: 1,
221        });
222
223        assert!(issue.is_error());
224        assert!(!issue.is_warning());
225        assert_eq!(issue.code, "AHB001");
226        assert_eq!(issue.field_path.as_deref(), Some("SG2/NAD/C082/3039"));
227        assert_eq!(issue.rule.as_deref(), Some("Muss [182] ∧ [152]"));
228        assert_eq!(issue.segment_position.unwrap().segment_number, 5);
229    }
230
231    #[test]
232    fn test_issue_display() {
233        let issue = ValidationIssue::new(
234            Severity::Error,
235            ValidationCategory::Ahb,
236            "AHB001",
237            "Required field missing",
238        )
239        .with_field_path("NAD");
240
241        let display = format!("{issue}");
242        assert!(display.contains("[ERROR]"));
243        assert!(display.contains("AHB001"));
244        assert!(display.contains("Required field missing"));
245        assert!(display.contains("at NAD"));
246    }
247
248    #[test]
249    fn test_issue_serialization() {
250        let issue = ValidationIssue::new(
251            Severity::Warning,
252            ValidationCategory::Code,
253            "COD002",
254            "Code not allowed for PID",
255        );
256
257        let json = serde_json::to_string_pretty(&issue).unwrap();
258        // bo4e_path should be absent from JSON when None (skip_serializing_if)
259        assert!(!json.contains("bo4e_path"));
260        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
261        assert_eq!(deserialized.code, "COD002");
262        assert_eq!(deserialized.severity, Severity::Warning);
263        assert!(deserialized.bo4e_path.is_none());
264    }
265
266    #[test]
267    fn test_bo4e_path_builder_and_serialization() {
268        let issue = ValidationIssue::new(
269            Severity::Error,
270            ValidationCategory::Ahb,
271            "AHB001",
272            "Required field missing",
273        )
274        .with_field_path("SG4/SG5/LOC/C517/3225")
275        .with_bo4e_path("stammdaten.Marktlokation.marktlokationsId");
276
277        assert_eq!(
278            issue.bo4e_path.as_deref(),
279            Some("stammdaten.Marktlokation.marktlokationsId")
280        );
281
282        let json = serde_json::to_string_pretty(&issue).unwrap();
283        assert!(json.contains("bo4e_path"));
284        assert!(json.contains("stammdaten.Marktlokation.marktlokationsId"));
285
286        let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
287        assert_eq!(
288            deserialized.bo4e_path.as_deref(),
289            Some("stammdaten.Marktlokation.marktlokationsId")
290        );
291    }
292
293    #[test]
294    fn test_category_display() {
295        assert_eq!(format!("{}", ValidationCategory::Structure), "Structure");
296        assert_eq!(format!("{}", ValidationCategory::Ahb), "AHB");
297    }
298
299    #[test]
300    fn test_position_from_edifact_primitives() {
301        let edifact_pos = edifact_primitives::SegmentPosition::new(3, 100, 1);
302        let pos: SegmentPosition = edifact_pos.into();
303        assert_eq!(pos.segment_number, 3);
304        assert_eq!(pos.byte_offset, 100);
305        assert_eq!(pos.message_number, 1);
306    }
307}