Skip to main content

edifact_rs/
error.rs

1use thiserror::Error;
2
3/// Wrapper around [`std::io::Error`] that implements [`PartialEq`] by comparing [`std::io::ErrorKind`].
4///
5/// This allows `EdifactError` to derive `PartialEq` without requiring `std::io::Error: PartialEq`.
6#[derive(Debug)]
7pub struct IoError(pub std::io::Error);
8
9impl PartialEq for IoError {
10    fn eq(&self, other: &Self) -> bool {
11        self.0.kind() == other.0.kind()
12    }
13}
14
15impl std::fmt::Display for IoError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        self.0.fmt(f)
18    }
19}
20
21impl std::error::Error for IoError {
22    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
23        self.0.source()
24    }
25}
26
27impl From<std::io::Error> for IoError {
28    fn from(e: std::io::Error) -> Self {
29        Self(e)
30    }
31}
32
33/// All errors produced by `edifact-rs`.
34///
35/// # Error Variants
36///
37/// All variants that include an offset carry byte position information from the input stream.
38/// This data enables precise error location reporting in diagnostics.
39#[derive(Debug, Error, PartialEq)]
40#[non_exhaustive]
41pub enum EdifactError {
42    /// Unexpected end of input while parsing.
43    ///
44    /// This typically occurs when a segment terminator or expected delimiter
45    /// is not found before the end of the input stream.
46    #[error("unexpected end of input at byte offset {offset}")]
47    UnexpectedEof {
48        /// Byte offset where the parser exhausted input.
49        offset: usize,
50    },
51
52    /// Invalid byte encountered in a delimiter context.
53    ///
54    /// Delimiters must be precisely ASCII characters from the UNA service string advice.
55    /// Any other byte is invalid in delimiter position.
56    #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
57    InvalidDelimiter {
58        /// Unexpected delimiter byte.
59        byte: u8,
60        /// Byte offset where the delimiter was observed.
61        offset: usize,
62    },
63
64    /// Invalid UTF-8 sequence in parsed text.
65    ///
66    /// While EDIFACT operates on bytes, segments and elements are expected to contain
67    /// valid UTF-8 text. Non-UTF-8 sequences are rejected at parse time.
68    #[error("invalid EDIFACT text at byte offset {offset}")]
69    InvalidText {
70        /// Byte offset where invalid UTF-8 text starts.
71        offset: usize,
72    },
73
74    /// Invalid release-character escape sequence in parsed text.
75    ///
76    /// The release character (`?` by default) must be followed by one escaped byte.
77    /// A trailing release character without a following byte is malformed.
78    #[error("invalid release sequence at byte offset {offset}: dangling release character")]
79    InvalidReleaseSequence {
80        /// Byte offset of the dangling release character.
81        offset: usize,
82    },
83
84    /// UNZ interchange message count does not match the number of UNH/UNT pairs found.
85    ///
86    /// The `UNZ` segment declares the number of messages in the interchange,
87    /// but the actual number of `UNH`/`UNT` pairs observed differs.
88    #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
89    MessageCountMismatch {
90        /// Message count declared in the UNZ segment.
91        expected: u32,
92        /// Actual number of UNH/UNT pairs observed.
93        actual: u32,
94    },
95
96    /// UNT segment count does not match the actual number of segments in the message.
97    ///
98    /// The `UNT` segment declares the number of segments in the message (including `UNH`/`UNT`),
99    /// but the actual count differs.
100    #[error("segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}")]
101    SegmentCountMismatch {
102        /// Segment count declared in the UNT segment.
103        expected: u32,
104        /// Actual number of segments observed.
105        actual: u32,
106        /// Message reference from the UNH segment.
107        message_ref: String,
108    },
109
110    /// Invalid or malformed segment tag.
111    ///
112    /// Segment tags must be exactly 3 ASCII uppercase letters.
113    #[error("invalid segment tag {0:?}")]
114    InvalidSegmentTag(String),
115
116    /// Invalid UNA service string advice.
117    ///
118    /// If present, the UNA segment must be exactly 9 bytes: "UNA" followed by 6 service characters.
119    #[error("invalid UNA service string advice: must be exactly 9 bytes")]
120    InvalidUna,
121
122    /// Missing required element in a segment.
123    ///
124    /// Certain segments require specific elements to be present. This error indicates
125    /// a mandatory element was not found.
126    #[error("missing required element {element_index} in segment {tag}")]
127    MissingRequiredElement {
128        /// Segment tag containing the missing element.
129        tag: String,
130        /// Zero-based required element index.
131        element_index: usize,
132    },
133
134    /// Output serialization produced invalid UTF-8.
135    ///
136    /// This is an internal consistency error; the writer should never produce non-UTF-8 output.
137    /// If this occurs, it indicates a bug in the serialization logic.
138    #[error("serialized output contains invalid UTF-8")]
139    InvalidUtf8,
140
141    /// I/O error from reading or writing.
142    #[error(transparent)]
143    Io(#[from] IoError),
144
145    // ── validation variants (E010–E020) ────────────────────────────────────
146    /// Segment is not valid for the current message type.
147    ///
148    /// Structural validation found a segment that should not appear in this message.
149    #[error("segment {tag} is not valid for message type {message_type}")]
150    InvalidSegmentForMessage {
151        /// Segment tag that is not allowed for the message type.
152        tag: String,
153        /// Message type used for structural validation.
154        message_type: String,
155        /// Segment tag byte offset.
156        offset: usize,
157    },
158
159    /// Element count in segment exceeds or falls short of directory definition.
160    ///
161    /// Validation against directory metadata found an element count mismatch.
162    #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
163    InvalidElementCount {
164        /// Segment tag with wrong arity.
165        tag: String,
166        /// Minimum allowed element count.
167        min: usize,
168        /// Maximum allowed element count.
169        max: usize,
170        /// Actual element count found.
171        actual: usize,
172        /// Segment start byte offset.
173        offset: usize,
174    },
175
176    /// Component count in a composite element is invalid.
177    ///
178    /// A composite data element does not have the expected number of components.
179    #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
180    InvalidComponentCount {
181        /// Segment tag containing the composite.
182        tag: String,
183        /// Zero-based element index of the composite.
184        element_index: usize,
185        /// Expected component count.
186        expected: u8,
187        /// Actual component count found.
188        actual: u8,
189        /// Segment start byte offset.
190        offset: usize,
191    },
192
193    /// Code-list value is not valid.
194    ///
195    /// The value appears in a field that should contain a code from a specific code list,
196    /// but the value is not in that code list.
197    #[error(
198        "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
199    )]
200    InvalidCodeValue {
201        /// Segment tag containing the invalid value.
202        tag: String,
203        /// Zero-based element index containing the invalid code.
204        element_index: usize,
205        /// Invalid code value observed.
206        value: String,
207        /// Data element code list identifier.
208        code_list: String,
209        /// Segment start byte offset.
210        offset: usize,
211        /// Optional remediation suggestion from the code-list lookup function.
212        suggestion: Option<&'static str>,
213    },
214
215    /// A required segment is missing from the message.
216    ///
217    /// Structural validation found that a mandatory segment is absent.
218    #[error("required segment {tag} is missing from message (position {expected_position})")]
219    MissingSegment {
220        /// Missing segment tag.
221        tag: String,
222        /// Human-readable position hint.
223        expected_position: String,
224    },
225
226    /// Qualifier does not match expected value for segment.
227    ///
228    /// A qualified segment (e.g., NAD+MS) has a qualifier that does not match expected.
229    #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
230    QualifierMismatch {
231        /// Segment tag whose qualifier mismatched.
232        tag: String,
233        /// Actual qualifier found.
234        actual: String,
235        /// Expected qualifier value.
236        expected: String,
237        /// Segment start byte offset.
238        offset: usize,
239    },
240
241    /// Conditional requirement not met.
242    ///
243    /// A segment or element is conditionally required based on another element's value,
244    /// but the condition was not satisfied.
245    #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
246    ConditionalRequirementNotMet {
247        /// Segment tag that violated a conditional rule.
248        tag: String,
249        /// Zero-based element index governed by the condition.
250        element_index: usize,
251        /// Condition text describing the rule.
252        condition: String,
253        /// Segment start byte offset.
254        offset: usize,
255    },
256
257    /// Aggregate validation failure from strict validation mode.
258    #[error("validation failed with {error_count} issue(s); first issue: {first_message}")]
259    ValidationFailed {
260        /// Number of collected validation issues.
261        error_count: usize,
262        /// First issue message for quick context.
263        first_message: String,
264    },
265
266    /// Segment exceeded the configured maximum byte length.
267    ///
268    /// Returned by reader-based parsers when an unterminated segment accumulates more
269    /// bytes than the configured `max_segment_bytes` limit in [`ReaderConfig`].  This
270    /// prevents resource exhaustion on adversarially crafted or truncated input that
271    /// never emits a segment terminator.
272    ///
273    /// [`ReaderConfig`]: crate::ReaderConfig
274    #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
275    SegmentTooLong {
276        /// Byte offset where the overlong segment started.
277        offset: usize,
278        /// Configured maximum segment byte length.
279        limit: usize,
280    },
281}
282
283
284
285impl From<std::io::Error> for EdifactError {
286    fn from(e: std::io::Error) -> Self {
287        Self::Io(IoError(e))
288    }
289}
290
291impl EdifactError {
292    /// Stable diagnostic code for this error variant.
293    #[must_use]
294    pub const fn stable_code(&self) -> &'static str {
295        match self {
296            Self::UnexpectedEof { .. } => "E001",
297            Self::InvalidDelimiter { .. } => "E002",
298            Self::InvalidText { .. } => "E003",
299            Self::MessageCountMismatch { .. } => "E004",
300            Self::SegmentCountMismatch { .. } => "E005",
301            Self::InvalidSegmentTag(_) => "E006",
302            Self::InvalidUna => "E007",
303            Self::MissingRequiredElement { .. } => "E008",
304            Self::InvalidUtf8 => "E009",
305            Self::Io(_) => "E010",
306            Self::InvalidSegmentForMessage { .. } => "E011",
307            Self::InvalidElementCount { .. } => "E012",
308            Self::InvalidComponentCount { .. } => "E013",
309            Self::InvalidCodeValue { .. } => "E014",
310            Self::MissingSegment { .. } => "E015",
311            Self::QualifierMismatch { .. } => "E016",
312            Self::ConditionalRequirementNotMet { .. } => "E017",
313            Self::ValidationFailed { .. } => "E018",
314            Self::InvalidReleaseSequence { .. } => "E019",
315            Self::SegmentTooLong { .. } => "E020",
316        }
317    }
318
319    /// Stable recovery hint for common malformed input and validation cases.
320    #[must_use]
321    pub fn recovery_hint(&self) -> Option<&'static str> {
322        match self {
323            Self::UnexpectedEof { .. } => {
324                Some("Ensure every segment ends with the configured segment terminator")
325            }
326            Self::InvalidDelimiter { .. } => {
327                Some("Check UNA service string advice and delimiter bytes in the payload")
328            }
329            Self::InvalidText { .. } => {
330                Some("Input must be valid UTF-8 text for segment and element values")
331            }
332            Self::InvalidReleaseSequence { .. } => {
333                Some("Release character must escape one following byte; trailing '?' is invalid")
334            }
335            Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
336            Self::InvalidUna => {
337                Some("UNA must be exactly 9 bytes: 'UNA' followed by 6 service characters")
338            }
339            Self::MissingRequiredElement { .. } => {
340                Some("Provide all mandatory elements for the segment per directory rules")
341            }
342            Self::InvalidSegmentForMessage { .. } => {
343                Some("Remove unsupported segment or switch to the correct message type")
344            }
345            Self::InvalidElementCount { .. } => {
346                Some("Adjust the segment element count to the allowed min/max range")
347            }
348            Self::InvalidComponentCount { .. } => {
349                Some("Fix composite element arity to match the expected component count")
350            }
351            Self::InvalidCodeValue { .. } => {
352                Some("Use a value from the referenced code list for this element")
353            }
354            Self::MissingSegment { .. } => {
355                Some("Insert the required segment at the expected position")
356            }
357            Self::QualifierMismatch { .. } => {
358                Some("Set the segment qualifier to the expected value")
359            }
360            Self::ConditionalRequirementNotMet { .. } => {
361                Some("When the condition is met, include the conditionally required element")
362            }
363            Self::SegmentTooLong { limit, .. } => {
364                let _ = limit; // used in the error message; hint is generic
365                Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
366            }
367            Self::ValidationFailed { .. }
368            | Self::MessageCountMismatch { .. }
369            | Self::SegmentCountMismatch { .. }
370            | Self::InvalidUtf8
371            | Self::Io(_) => None,
372        }
373    }
374}
375
376#[cfg(feature = "diagnostics")]
377#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
378impl miette::Diagnostic for EdifactError {
379    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
380        Some(Box::new(self.stable_code()))
381    }
382
383    fn severity(&self) -> Option<miette::Severity> {
384        match self {
385            Self::InvalidCodeValue { .. }
386            | Self::InvalidComponentCount { .. }
387            | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
388            _ => Some(miette::Severity::Error),
389        }
390    }
391
392    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
393        match self {
394            // Static text — no allocation needed.
395            Self::InvalidUna => Some(Box::new(
396                "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
397            )),
398            Self::InvalidUtf8 => Some(Box::new(
399                "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
400            )),
401            // Dynamic help text.
402            Self::UnexpectedEof { offset } => Some(Box::new(format!(
403                "Check that all segments are terminated with the segment terminator (usually '). \
404                 Reached end at offset {offset}",
405            ))),
406            Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
407                "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
408                 Check UNA configuration",
409            ))),
410            Self::InvalidText { offset } => Some(Box::new(format!(
411                "The byte sequence at offset {offset} contains invalid UTF-8. \
412                 Ensure input is valid UTF-8",
413            ))),
414            Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
415                "Release character at offset {offset} is dangling. \
416                 Ensure '?' is followed by an escaped byte",
417            ))),
418            Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
419                "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
420                 Check the UNZ message count",
421            ))),
422            Self::SegmentCountMismatch { expected, actual, message_ref } => Some(Box::new(format!(
423                "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
424                 Check the UNT segment count",
425            ))),
426            Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
427                "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
428            ))),
429            Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
430                "Segment {tag} requires element at index {element_index}",
431            ))),
432            Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
433            Self::InvalidSegmentForMessage { tag, message_type, .. } => Some(Box::new(format!(
434                "Segment {tag} should not appear in a {message_type} message. \
435                 Check the directory definition",
436            ))),
437            Self::InvalidElementCount { tag, min, max, actual, .. } => Some(Box::new(format!(
438                "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
439                 Check segment structure",
440            ))),
441            Self::InvalidComponentCount { tag, element_index, expected, actual, .. } => {
442                Some(Box::new(format!(
443                    "In segment {tag}, element {element_index} should have {expected} components \
444                     but has {actual}. Check element structure",
445                )))
446            }
447            Self::InvalidCodeValue { tag, element_index, value, code_list, .. } => {
448                Some(Box::new(format!(
449                    "Value '{value}' in segment {tag} element {element_index} is not in the \
450                     {code_list} code list. Check the directory for valid codes",
451                )))
452            }
453            Self::MissingSegment { tag, expected_position } => Some(Box::new(format!(
454                "Segment {tag} is required at position {expected_position} but is missing. \
455                 Add this segment to the message",
456            ))),
457            Self::QualifierMismatch { tag, actual, expected, .. } => Some(Box::new(format!(
458                "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
459                 Check the segment's first component",
460            ))),
461            Self::ConditionalRequirementNotMet { tag, element_index, condition, .. } => {
462                Some(Box::new(format!(
463                    "In segment {tag}, element {element_index} is conditionally required when: \
464                     {condition}. Check if the condition is met",
465                )))
466            }
467            Self::ValidationFailed { error_count, first_message } => Some(Box::new(format!(
468                "Validation found {error_count} issue(s). Start by fixing: {first_message}",
469            ))),
470            Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
471                "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
472                 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
473                 or verify the input for a missing segment terminator",
474            ))),
475        }
476    }
477}
478
479// ── validation report ─────────────────────────────────────────────────────────
480
481/// Priority level for a validation error or warning.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
483pub enum ValidationSeverity {
484    /// Structural parse failure; processing cannot continue.
485    Critical,
486    /// Structural validation failed; message is invalid.
487    Error,
488    /// Data validation warning (e.g., code-list mismatch); message may be usable.
489    Warning,
490    /// Informational note; message is valid but noteworthy.
491    Info,
492}
493
494/// A structured validation issue.
495#[derive(Debug, Clone, PartialEq)]
496pub struct ValidationIssue {
497    /// Stable error code, if known.
498    pub error_code: Option<&'static str>,
499    /// The severity of this issue.
500    pub severity: ValidationSeverity,
501    /// The error or warning message.
502    pub message: String,
503    /// Byte offset in the source (if available).
504    pub offset: Option<usize>,
505    /// Segment tag involved (if known).
506    pub segment_tag: Option<String>,
507    /// Profile/MIG rule identifier, if applicable.
508    pub rule_id: Option<String>,
509    /// Element index (0-based), if known.
510    pub element_index: Option<u8>,
511    /// Component index (0-based), if known.
512    pub component_index: Option<u8>,
513    /// Suggested remediation (if available).
514    pub suggestion: Option<String>,
515}
516
517impl ValidationIssue {
518    /// Create a new validation issue.
519    pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
520        Self {
521            error_code: None,
522            severity,
523            message: message.into(),
524            offset: None,
525            segment_tag: None,
526            rule_id: None,
527            element_index: None,
528            component_index: None,
529            suggestion: None,
530        }
531    }
532
533    /// Set stable error code metadata.
534    pub fn with_error_code(mut self, code: &'static str) -> Self {
535        self.error_code = Some(code);
536        self
537    }
538
539    /// Set the offset for this issue.
540    pub fn with_offset(mut self, offset: usize) -> Self {
541        self.offset = Some(offset);
542        self
543    }
544
545    /// Set the segment tag for this issue.
546    pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
547        self.segment_tag = Some(tag.into());
548        self
549    }
550
551    /// Set the profile/MIG rule identifier for this issue.
552    pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
553        self.rule_id = Some(rule_id.into());
554        self
555    }
556
557    /// Set the element index (0-based) for this issue.
558    pub fn with_element_index(mut self, element_index: u8) -> Self {
559        self.element_index = Some(element_index);
560        self
561    }
562
563    /// Set the component index (0-based) for this issue.
564    pub fn with_component_index(mut self, component_index: u8) -> Self {
565        self.component_index = Some(component_index);
566        self
567    }
568
569    /// Set a suggestion for resolving this issue.
570    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
571        self.suggestion = Some(suggestion.into());
572        self
573    }
574}
575
576/// A collection of validation results: errors, warnings, and info.
577///
578/// Enables batch validation where all issues are collected instead of failing on the first error.
579#[derive(Debug, Clone, Default)]
580pub struct ValidationReport {
581    /// Critical and error-level issues.
582    pub errors: Vec<ValidationIssue>,
583    /// Warning-level issues.
584    pub warnings: Vec<ValidationIssue>,
585    /// Informational notes.
586    pub infos: Vec<ValidationIssue>,
587}
588
589impl ValidationReport {
590    /// Add an error to the report.
591    pub fn add_error(&mut self, issue: ValidationIssue) {
592        self.errors.push(issue);
593    }
594
595    /// Add a warning to the report.
596    pub fn add_warning(&mut self, issue: ValidationIssue) {
597        self.warnings.push(issue);
598    }
599
600    /// Add an info message to the report.
601    pub fn add_info(&mut self, issue: ValidationIssue) {
602        self.infos.push(issue);
603    }
604
605    /// Check if the report has any errors.
606    pub fn has_errors(&self) -> bool {
607        !self.errors.is_empty()
608    }
609
610    /// Check if the report has any warnings.
611    pub fn has_warnings(&self) -> bool {
612        !self.warnings.is_empty()
613    }
614
615    /// Get the total count of all issues.
616    pub fn total_issues(&self) -> usize {
617        self.errors.len() + self.warnings.len() + self.infos.len()
618    }
619
620    /// Check if the validation passed (no errors, but may have warnings).
621    pub fn is_valid(&self) -> bool {
622        self.errors.is_empty()
623    }
624
625    /// Convert to a Result: Ok if no errors, Err if errors exist.
626    pub fn result(self) -> Result<Self, Vec<ValidationIssue>> {
627        if self.is_valid() {
628            Ok(self)
629        } else {
630            Err(self.errors)
631        }
632    }
633
634    /// Iterate over all issues in severity buckets: errors, warnings, then infos.
635    pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
636        self.errors
637            .iter()
638            .chain(self.warnings.iter())
639            .chain(self.infos.iter())
640    }
641
642    /// Iterate over all issues in severity order.  Alias for [`iter_issues`][Self::iter_issues].
643    pub fn issues(&self) -> impl Iterator<Item = &ValidationIssue> {
644        self.iter_issues()
645    }
646
647    /// Return `true` if the report contains any issues (errors, warnings, or infos).
648    pub fn has_any_issues(&self) -> bool {
649        !self.errors.is_empty() || !self.warnings.is_empty() || !self.infos.is_empty()
650    }
651
652    /// Return all issues matching an exact profile/MIG rule identifier.
653    pub fn issues_for_rule_id<'a>(&'a self, rule_id: &str) -> Vec<&'a ValidationIssue> {
654        self.iter_issues()
655            .filter(|issue| issue.rule_id.as_deref() == Some(rule_id))
656            .collect()
657    }
658
659    /// Return a cloned report filtered by `pred`.
660    fn filter_report<F>(&self, pred: F) -> Self
661    where
662        F: Fn(&ValidationIssue) -> bool,
663    {
664        Self {
665            errors: self.errors.iter().filter(|i| pred(i)).cloned().collect(),
666            warnings: self.warnings.iter().filter(|i| pred(i)).cloned().collect(),
667            infos: self.infos.iter().filter(|i| pred(i)).cloned().collect(),
668        }
669    }
670
671    /// Return a cloned report containing only issues with an exact rule identifier.
672    pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
673        self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
674    }
675
676    /// Return a cloned report containing only issues whose rule identifier starts with `prefix`.
677    pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
678        self.filter_report(|issue| {
679            issue
680                .rule_id
681                .as_deref()
682                .is_some_and(|id| id.starts_with(prefix))
683        })
684    }
685
686    /// Return a deterministic, stable text representation for snapshots and logs.
687    pub fn render_deterministic(&self) -> String {
688        fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
689            let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
690            refs.sort_by(|left, right| {
691                left.offset
692                    .unwrap_or(usize::MAX)
693                    .cmp(&right.offset.unwrap_or(usize::MAX))
694                    .then_with(|| {
695                        left.segment_tag
696                            .as_deref()
697                            .unwrap_or("")
698                            .cmp(right.segment_tag.as_deref().unwrap_or(""))
699                    })
700                    .then_with(|| {
701                        left.rule_id
702                            .as_deref()
703                            .unwrap_or("")
704                            .cmp(right.rule_id.as_deref().unwrap_or(""))
705                    })
706                    .then_with(|| {
707                        left.element_index
708                            .unwrap_or(u8::MAX)
709                            .cmp(&right.element_index.unwrap_or(u8::MAX))
710                    })
711                    .then_with(|| {
712                        left.component_index
713                            .unwrap_or(u8::MAX)
714                            .cmp(&right.component_index.unwrap_or(u8::MAX))
715                    })
716                    .then_with(|| {
717                        left.error_code
718                            .unwrap_or("")
719                            .cmp(right.error_code.unwrap_or(""))
720                    })
721                    .then_with(|| left.message.cmp(&right.message))
722            });
723            refs
724        }
725
726        fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
727            use std::fmt::Write as _;
728            out.push_str("    - ");
729            out.push_str(&issue.message);
730            if let Some(code) = issue.error_code {
731                out.push_str(" [");
732                out.push_str(code);
733                out.push(']');
734            }
735            if let Some(seg) = &issue.segment_tag {
736                out.push_str(" [segment=");
737                out.push_str(seg);
738                out.push(']');
739            }
740            if let Some(rule_id) = &issue.rule_id {
741                out.push_str(" [rule=");
742                out.push_str(rule_id);
743                out.push(']');
744            }
745            if let Some(element_index) = issue.element_index {
746                write!(out, " [element={element_index}]").ok();
747            }
748            if let Some(component_index) = issue.component_index {
749                write!(out, " [component={component_index}]").ok();
750            }
751            if let Some(offset) = issue.offset {
752                write!(out, " [offset={offset}]").ok();
753            }
754            if let Some(suggestion) = &issue.suggestion {
755                out.push_str(" [hint=");
756                out.push_str(suggestion);
757                out.push(']');
758            }
759        }
760
761        use std::fmt::Write as _;
762        let mut out = String::from("Validation Report:");
763        let errors = sorted_refs(&self.errors);
764        let warnings = sorted_refs(&self.warnings);
765        let infos = sorted_refs(&self.infos);
766
767        if !errors.is_empty() {
768            write!(out, "\n  Errors ({})", errors.len()).ok();
769            for issue in &errors {
770                out.push('\n');
771                render_issue_line(&mut out, issue);
772            }
773        }
774        if !warnings.is_empty() {
775            write!(out, "\n  Warnings ({})", warnings.len()).ok();
776            for issue in &warnings {
777                out.push('\n');
778                render_issue_line(&mut out, issue);
779            }
780        }
781        if !infos.is_empty() {
782            write!(out, "\n  Info ({})", infos.len()).ok();
783            for issue in &infos {
784                out.push('\n');
785                render_issue_line(&mut out, issue);
786            }
787        }
788
789        out
790    }
791}
792
793#[cfg(feature = "diagnostics")]
794impl miette::Diagnostic for ValidationReport {
795    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
796        Some(Box::new("VALIDATION"))
797    }
798
799    fn severity(&self) -> Option<miette::Severity> {
800        if self.has_errors() {
801            Some(miette::Severity::Error)
802        } else if self.has_warnings() {
803            Some(miette::Severity::Warning)
804        } else {
805            Some(miette::Severity::Advice)
806        }
807    }
808
809    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
810        let msg = format!(
811            "Validation found {} error(s), {} warning(s), {} info(s)",
812            self.errors.len(),
813            self.warnings.len(),
814            self.infos.len()
815        );
816        Some(Box::new(msg))
817    }
818}
819
820impl std::fmt::Display for ValidationReport {
821    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822        write!(f, "{}", self.render_deterministic())
823    }
824}
825
826impl std::error::Error for ValidationReport {}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn validation_report_collects_errors() {
834        let mut report = ValidationReport::default();
835        report.add_error(
836            ValidationIssue::new(ValidationSeverity::Error, "Test error")
837                .with_segment("BGM")
838                .with_offset(42),
839        );
840        report.add_warning(ValidationIssue::new(
841            ValidationSeverity::Warning,
842            "Test warning",
843        ));
844
845        assert!(report.has_errors());
846        assert!(report.has_warnings());
847        assert_eq!(report.total_issues(), 2);
848        assert!(!report.is_valid());
849    }
850
851    #[test]
852    fn validation_report_result_conversion() {
853        let mut report = ValidationReport::default();
854        report.add_error(ValidationIssue::new(
855            ValidationSeverity::Error,
856            "Critical issue",
857        ));
858
859        let result = report.result();
860        assert!(result.is_err());
861    }
862
863    #[test]
864    fn validation_report_passes_when_no_errors() {
865        let mut report = ValidationReport::default();
866        report.add_warning(ValidationIssue::new(
867            ValidationSeverity::Warning,
868            "Just a warning",
869        ));
870
871        assert!(report.is_valid());
872        assert!(report.result().is_ok());
873    }
874
875    #[test]
876    fn validation_issue_builder() {
877        let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
878            .with_error_code("E013")
879            .with_offset(100)
880            .with_segment("NAD")
881            .with_rule_id("DEMO-P001")
882            .with_element_index(1)
883            .with_component_index(2)
884            .with_suggestion("Check element count");
885
886        assert_eq!(issue.error_code, Some("E013"));
887        assert_eq!(issue.message, "test message");
888        assert_eq!(issue.offset, Some(100));
889        assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
890        assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
891        assert_eq!(issue.element_index, Some(1));
892        assert_eq!(issue.component_index, Some(2));
893        assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
894    }
895
896    #[test]
897    fn validation_report_display() {
898        let mut report = ValidationReport::default();
899        report.add_error(
900            ValidationIssue::new(ValidationSeverity::Error, "Error 1")
901                .with_error_code("E011")
902                .with_offset(8),
903        );
904        report.add_warning(ValidationIssue::new(
905            ValidationSeverity::Warning,
906            "Warning 1",
907        ));
908        report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
909
910        let display_str = format!("{}", report);
911        assert!(display_str.contains("Errors (1)"));
912        assert!(display_str.contains("Warnings (1)"));
913        assert!(display_str.contains("Info (1)"));
914        assert!(display_str.contains("[E011]"));
915    }
916
917    #[test]
918    fn validation_report_render_is_deterministic() {
919        let mut report = ValidationReport::default();
920        report.add_error(
921            ValidationIssue::new(ValidationSeverity::Error, "later")
922                .with_segment("BGM")
923                .with_offset(20),
924        );
925        report.add_error(
926            ValidationIssue::new(ValidationSeverity::Error, "earlier")
927                .with_segment("UNH")
928                .with_offset(1),
929        );
930
931        let rendered = report.render_deterministic();
932        let first = rendered.find("earlier").expect("missing first issue");
933        let second = rendered.find("later").expect("missing second issue");
934        assert!(first < second, "expected deterministic sort by offset");
935    }
936
937    #[test]
938    fn recovery_hint_exists_for_common_malformed_cases() {
939        let err = EdifactError::InvalidReleaseSequence { offset: 10 };
940        assert!(err.recovery_hint().is_some());
941
942        let err = EdifactError::InvalidCodeValue {
943            tag: "BGM".to_owned(),
944            element_index: 0,
945            value: "X".to_owned(),
946            code_list: "1001".to_owned(),
947            offset: 0,
948            suggestion: None,
949        };
950        assert!(err.recovery_hint().is_some());
951    }
952
953    #[test]
954    fn validation_report_can_filter_by_rule_id() {
955        let mut report = ValidationReport::default();
956        report.add_error(
957            ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
958                .with_rule_id("ORDERS-P001"),
959        );
960        report.add_warning(
961            ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
962                .with_rule_id("INVOIC-P001"),
963        );
964        report.add_info(
965            ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
966                .with_rule_id("ORDERS-P002"),
967        );
968
969        let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
970        assert_eq!(only_orders_block.errors.len(), 1);
971        assert!(only_orders_block.warnings.is_empty());
972        assert!(only_orders_block.infos.is_empty());
973
974        let orders_family = report.filter_by_rule_prefix("ORDERS-");
975        assert_eq!(orders_family.total_issues(), 2);
976        assert!(orders_family.has_errors());
977        assert!(!orders_family.has_warnings());
978
979        let exact = report.issues_for_rule_id("INVOIC-P001");
980        assert_eq!(exact.len(), 1);
981        assert_eq!(exact[0].message, "invoic policy warning");
982    }
983}