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