Skip to main content

edifact_rs/
error.rs

1use std::sync::Arc;
2use thiserror::Error;
3
4/// Wrapper around [`std::io::Error`] that implements [`PartialEq`] by comparing [`std::io::ErrorKind`].
5///
6/// This allows `EdifactError` to derive `PartialEq` without requiring `std::io::Error: PartialEq`.
7#[derive(Debug)]
8pub struct IoError(pub(crate) std::io::Error);
9
10impl IoError {
11    /// Returns a reference to the underlying [`std::io::Error`].
12    pub fn inner(&self) -> &std::io::Error {
13        &self.0
14    }
15}
16
17impl PartialEq for IoError {
18    /// Equality is determined by [`std::io::ErrorKind`] only.
19    ///
20    /// Two `IoError` values with the same kind but different OS-level error codes
21    /// (or different messages) will compare as equal.  This is a deliberate
22    /// limitation: `std::io::Error` is not `PartialEq`, so kind-based comparison
23    /// is the only practical option that lets `EdifactError` derive `PartialEq`.
24    fn eq(&self, other: &Self) -> bool {
25        self.0.kind() == other.0.kind()
26    }
27}
28
29impl std::fmt::Display for IoError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        self.0.fmt(f)
32    }
33}
34
35impl std::error::Error for IoError {
36    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
37        self.0.source()
38    }
39}
40
41impl From<std::io::Error> for IoError {
42    fn from(e: std::io::Error) -> Self {
43        Self(e)
44    }
45}
46
47/// All errors produced by `edifact-rs`.
48///
49/// # Error Variants
50///
51/// All variants that include an offset carry byte position information from the input stream.
52/// This data enables precise error location reporting in diagnostics.
53#[derive(Debug, Error, PartialEq)]
54#[non_exhaustive]
55pub enum EdifactError {
56    /// Unexpected end of input while parsing.
57    ///
58    /// This typically occurs when a segment terminator or expected delimiter
59    /// is not found before the end of the input stream.
60    #[error("unexpected end of input at byte offset {offset}")]
61    UnexpectedEof {
62        /// Byte offset where the parser exhausted input.
63        offset: usize,
64    },
65
66    /// Invalid byte encountered in a delimiter context.
67    ///
68    /// Delimiters must be precisely ASCII characters from the UNA service string advice.
69    /// Any other byte is invalid in delimiter position.
70    #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
71    InvalidDelimiter {
72        /// Unexpected delimiter byte.
73        byte: u8,
74        /// Byte offset where the delimiter was observed.
75        offset: usize,
76    },
77
78    /// Invalid UTF-8 sequence in parsed text.
79    ///
80    /// While EDIFACT operates on bytes, segments and elements are expected to contain
81    /// valid UTF-8 text. Non-UTF-8 sequences are rejected at parse time.
82    #[error("invalid EDIFACT text at byte offset {offset}")]
83    InvalidText {
84        /// Byte offset where invalid UTF-8 text starts.
85        offset: usize,
86    },
87
88    /// Invalid release-character escape sequence in parsed text.
89    ///
90    /// The release character (`?` by default) must be followed by one escaped byte.
91    /// A trailing release character without a following byte is malformed.
92    #[error("invalid release sequence at byte offset {offset}: dangling release character")]
93    InvalidReleaseSequence {
94        /// Byte offset of the dangling release character.
95        offset: usize,
96    },
97
98    /// UNZ interchange message count does not match the number of UNH/UNT pairs found.
99    ///
100    /// The `UNZ` segment declares the number of messages in the interchange,
101    /// but the actual number of `UNH`/`UNT` pairs observed differs.
102    #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
103    MessageCountMismatch {
104        /// Message count declared in the UNZ segment.
105        expected: u32,
106        /// Actual number of UNH/UNT pairs observed.
107        actual: u32,
108    },
109
110    /// UNT segment count does not match the actual number of segments in the message.
111    ///
112    /// The `UNT` segment declares the number of segments in the message (including `UNH`/`UNT`),
113    /// but the actual count differs.
114    #[error(
115        "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
116    )]
117    SegmentCountMismatch {
118        /// Segment count declared in the UNT segment.
119        expected: u32,
120        /// Actual number of segments observed.
121        actual: u32,
122        /// Message reference from the UNH segment.
123        message_ref: String,
124    },
125
126    /// Invalid or malformed segment tag.
127    ///
128    /// Segment tags must be exactly 3 ASCII uppercase letters.
129    #[error("invalid segment tag {0:?}")]
130    InvalidSegmentTag(String),
131
132    /// Invalid UNA service string advice.
133    ///
134    /// If present, the UNA segment must be exactly 9 bytes: `"UNA"` followed by
135    /// 6 service characters.  The four active characters (element separator,
136    /// component separator, release character, and segment terminator) must be
137    /// mutually distinct and must not be ASCII whitespace.  The decimal mark and
138    /// repetition separator characters are not validated by this check.
139    #[error("invalid UNA service string advice")]
140    InvalidUna,
141
142    /// Missing required element in a segment.
143    ///
144    /// Certain segments require specific elements to be present. This error indicates
145    /// a mandatory element was not found.
146    #[error("missing required element {element_index} in segment {tag}")]
147    MissingRequiredElement {
148        /// Segment tag containing the missing element.
149        tag: String,
150        /// Zero-based required element index.
151        element_index: usize,
152    },
153
154    /// Missing required component in a composite element.
155    ///
156    /// The element is present, but the required component at the given index is absent or empty.
157    #[error(
158        "missing required component {component_index} in element {element_index} of segment {tag}"
159    )]
160    MissingRequiredComponent {
161        /// Segment tag containing the composite element.
162        tag: String,
163        /// Zero-based element index of the composite.
164        element_index: usize,
165        /// Zero-based component index that was absent.
166        component_index: usize,
167    },
168
169    /// Output serialization produced invalid UTF-8.
170    ///
171    /// This is an internal consistency error; the writer should never produce non-UTF-8 output.
172    /// If this occurs, it indicates a bug in the serialization logic.
173    #[error("serialized output contains invalid UTF-8")]
174    InvalidUtf8,
175
176    /// I/O error from reading or writing.
177    #[error(transparent)]
178    Io(#[from] IoError),
179
180    // ── validation variants (E010–E020) ────────────────────────────────────
181    /// Segment is not valid for the current message type.
182    ///
183    /// Structural validation found a segment that should not appear in this message.
184    #[error("segment {tag} is not valid for message type {message_type}")]
185    InvalidSegmentForMessage {
186        /// Segment tag that is not allowed for the message type.
187        tag: String,
188        /// Message type used for structural validation.
189        message_type: String,
190        /// Segment tag byte offset.
191        offset: usize,
192    },
193
194    /// Element count in segment exceeds or falls short of directory definition.
195    ///
196    /// Validation against directory metadata found an element count mismatch.
197    #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
198    InvalidElementCount {
199        /// Segment tag with wrong arity.
200        tag: String,
201        /// Minimum allowed element count.
202        min: usize,
203        /// Maximum allowed element count.
204        max: usize,
205        /// Actual element count found.
206        actual: usize,
207        /// Segment start byte offset.
208        offset: usize,
209    },
210
211    /// Component count in a composite element is invalid.
212    ///
213    /// A composite data element does not have the expected number of components.
214    #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
215    InvalidComponentCount {
216        /// Segment tag containing the composite.
217        tag: String,
218        /// Zero-based element index of the composite.
219        element_index: usize,
220        /// Expected component count.
221        expected: u8,
222        /// Actual component count found.
223        actual: u8,
224        /// Segment start byte offset.
225        offset: usize,
226    },
227
228    /// Code-list value is not valid.
229    ///
230    /// The value appears in a field that should contain a code from a specific code list,
231    /// but the value is not in that code list.
232    #[error(
233        "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
234    )]
235    InvalidCodeValue {
236        /// Segment tag containing the invalid value.
237        tag: String,
238        /// Zero-based element index containing the invalid code.
239        element_index: usize,
240        /// Invalid code value observed.
241        value: String,
242        /// Data element code list identifier.
243        code_list: String,
244        /// Segment start byte offset.
245        offset: usize,
246        /// Optional remediation suggestion from the code-list lookup function.
247        suggestion: Option<&'static str>,
248    },
249
250    /// A required segment is missing from the message.
251    ///
252    /// Structural validation found that a mandatory segment is absent.
253    #[error("required segment {tag} is missing from message (position {expected_position})")]
254    MissingSegment {
255        /// Missing segment tag.
256        tag: String,
257        /// Human-readable position hint.
258        expected_position: String,
259    },
260
261    /// Qualifier does not match expected value for segment.
262    ///
263    /// A qualified segment (e.g., NAD+MS) has a qualifier that does not match expected.
264    #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
265    QualifierMismatch {
266        /// Segment tag whose qualifier mismatched.
267        tag: String,
268        /// Actual qualifier found.
269        actual: String,
270        /// Expected qualifier value.
271        expected: String,
272        /// Segment start byte offset.
273        offset: usize,
274    },
275
276    /// Conditional requirement not met.
277    ///
278    /// A segment or element is conditionally required based on another element's value,
279    /// but the condition was not satisfied.
280    #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
281    ConditionalRequirementNotMet {
282        /// Segment tag that violated a conditional rule.
283        tag: String,
284        /// Zero-based element index governed by the condition.
285        element_index: usize,
286        /// Condition text describing the rule.
287        condition: String,
288        /// Segment start byte offset.
289        offset: usize,
290    },
291
292    /// Validation failed and the full [`ValidationReport`] is preserved.
293    ///
294    /// Returned by validation helpers when errors are found.  Provides programmatic
295    /// access to all issues, warnings, and infos.
296    ///
297    /// # Example
298    ///
299    /// ```rust,ignore
300    /// match my_fn() {
301    ///     Err(EdifactError::ValidationErrors { report, .. }) => {
302    ///         for issue in report.errors() {
303    ///             eprintln!("{}", issue);
304    ///         }
305    ///     }
306    ///     other => { /* ... */ }
307    /// }
308    /// ```
309    #[error("validation failed with {error_count} error(s)")]
310    ValidationErrors {
311        /// Number of error-severity issues in the report.
312        error_count: usize,
313        /// Full report with all errors, warnings, and infos.
314        report: Box<ValidationReport>,
315    },
316
317    /// Segment exceeded the configured maximum byte length.
318    ///
319    /// Returned by reader-based parsers when an unterminated segment accumulates more
320    /// bytes than the configured `max_segment_bytes` limit in [`ReaderConfig`].  This
321    /// prevents resource exhaustion on adversarially crafted or truncated input that
322    /// never emits a segment terminator.
323    ///
324    /// [`ReaderConfig`]: crate::ReaderConfig
325    #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
326    SegmentTooLong {
327        /// Byte offset where the overlong segment started.
328        offset: usize,
329        /// Configured maximum segment byte length.
330        limit: usize,
331    },
332
333    /// No handler was registered in [`crate::MessageDispatch`] for this message type.
334    ///
335    /// Returned by [`crate::MessageDispatch::dispatch`] when the message-type
336    /// extracted from the `UNH` segment does not match any registered handler
337    /// and no fallback was configured.
338    #[error("no handler registered for message type {message_type}")]
339    UnexpectedMessageType {
340        /// The unhandled message type string from the `UNH` segment.
341        message_type: String,
342    },
343
344    /// An interchange or message contains more segments or messages than can be
345    /// represented in a `u32` counter (> 4 294 967 295).
346    ///
347    /// This is effectively unreachable in practice — no real-world EDIFACT
348    /// interchange has billions of segments — but the parser returns this error
349    /// rather than silently saturating or wrapping the counter.
350    #[error("interchange too large: count {count} exceeds u32::MAX")]
351    InterchangeTooLarge {
352        /// The count that could not be represented as `u32`.
353        count: u64,
354    },
355
356    /// An [`crate::EventEmitter`] received events in an invalid sequence.
357    ///
358    /// This indicates a programming error in the caller's serialization code:
359    /// for example, emitting an [`crate::EdifactEvent::Element`] without a prior
360    /// [`crate::EdifactEvent::StartSegment`], or emitting
361    /// [`crate::EdifactEvent::ComponentElement`] without a preceding
362    /// [`crate::EdifactEvent::Element`].
363    #[error("invalid event sequence: {message}")]
364    InvalidEventSequence {
365        /// Description of the protocol violation.
366        message: &'static str,
367    },
368
369    /// An [`crate::OwnedElementRef`] has `position = 0`, which is never valid.
370    ///
371    /// Element positions are one-based: position 1 refers to the first element
372    /// slot.  Position 0 is reserved and invalid.  Use [`crate::OwnedElementRef::new`]
373    /// to catch this at construction time.
374    #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
375    InvalidElementPosition,
376
377    /// Two [`crate::ProfileRulePack`] values with incompatible release scopes were composed.
378    ///
379    /// When composing packs via [`crate::ProfileRulePack::extend_from`] or
380    /// [`crate::ProfileRulePack::merge_with_override`], both packs must either
381    /// share the same release scope or at most one may carry a scope.
382    #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
383    #[non_exhaustive]
384    IncompatibleReleaseScopes {
385        /// Release scope of the pack being composed into.
386        current: String,
387        /// Release scope of the pack being composed in.
388        incoming: String,
389    },
390
391    /// A field value failed semantic validation (e.g. wrong format, out-of-range).
392    ///
393    /// Distinct from [`InvalidCodeValue`][Self::InvalidCodeValue] which is for
394    /// code-list membership checks.  Use this variant when a free-text or numeric
395    /// field contains a value that is structurally invalid for its purpose.
396    #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
397    InvalidFieldValue {
398        /// Segment tag that contains the invalid field.
399        tag: String,
400        /// Zero-based element index of the invalid field.
401        element_index: usize,
402        /// The invalid value that was observed.
403        value: String,
404    },
405
406    /// A data or component element token appeared before the first segment tag.
407    ///
408    /// EDIFACT syntax requires that every data element follows a segment tag.
409    /// A data element token encountered before any tag (e.g. after a stray
410    /// separator at the start of the stream) is a protocol violation.
411    ///
412    /// Unlike stray segment terminators (which are tolerated as blank lines),
413    /// stray data tokens indicate encoding corruption or a partial write.
414    #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
415    UnexpectedDataToken {
416        /// Byte offset of the stray token.
417        offset: usize,
418    },
419
420    /// The input contains EDIFACT functional group segments (`UNG`/`UNE`).
421    ///
422    /// Functional groups are defined in ISO 9735 but are rarely used in practice
423    /// and are not supported by this library.  Strip `UNG`/`UNE` wrappers before
424    /// calling `validate_envelope`, or process the interchange as raw segments.
425    #[error(
426        "functional group segments (UNG/UNE) at byte offset {offset} are not supported; \
427         strip them before calling validate_envelope"
428    )]
429    FunctionalGroupNotSupported {
430        /// Byte offset of the first `UNG` or `UNE` segment found.
431        offset: usize,
432    },
433}
434
435impl From<std::io::Error> for EdifactError {
436    fn from(e: std::io::Error) -> Self {
437        Self::Io(IoError(e))
438    }
439}
440
441impl EdifactError {
442    /// Stable diagnostic code for this error variant.
443    #[must_use]
444    pub const fn stable_code(&self) -> &'static str {
445        match self {
446            Self::UnexpectedEof { .. } => "E001",
447            Self::InvalidDelimiter { .. } => "E002",
448            Self::InvalidText { .. } => "E003",
449            Self::MessageCountMismatch { .. } => "E004",
450            Self::SegmentCountMismatch { .. } => "E005",
451            Self::InvalidSegmentTag(_) => "E006",
452            Self::InvalidUna => "E007",
453            Self::MissingRequiredElement { .. } => "E008",
454            Self::InvalidUtf8 => "E009",
455            Self::Io(_) => "E010",
456            Self::InvalidSegmentForMessage { .. } => "E011",
457            Self::InvalidElementCount { .. } => "E012",
458            Self::InvalidComponentCount { .. } => "E013",
459            Self::InvalidCodeValue { .. } => "E014",
460            Self::MissingSegment { .. } => "E015",
461            Self::QualifierMismatch { .. } => "E016",
462            Self::ConditionalRequirementNotMet { .. } => "E017",
463            // E018 is permanently retired (was ValidationFailed, removed in 0.8.0)
464            Self::InvalidReleaseSequence { .. } => "E019",
465            Self::SegmentTooLong { .. } => "E020",
466            Self::MissingRequiredComponent { .. } => "E021",
467            Self::UnexpectedMessageType { .. } => "E022",
468            Self::InterchangeTooLarge { .. } => "E023",
469            Self::InvalidEventSequence { .. } => "E024",
470            Self::InvalidElementPosition => "E025",
471            Self::IncompatibleReleaseScopes { .. } => "E026",
472            Self::InvalidFieldValue { .. } => "E027",
473            Self::UnexpectedDataToken { .. } => "E028",
474            Self::FunctionalGroupNotSupported { .. } => "E029",
475            Self::ValidationErrors { .. } => "E030",
476        }
477    }
478
479    /// Stable recovery hint for common malformed input and validation cases.
480    #[must_use]
481    pub fn recovery_hint(&self) -> Option<&'static str> {
482        match self {
483            Self::UnexpectedEof { .. } => {
484                Some("Ensure every segment ends with the configured segment terminator")
485            }
486            Self::InvalidDelimiter { .. } => {
487                Some("Check UNA service string advice and delimiter bytes in the payload")
488            }
489            Self::InvalidText { .. } => {
490                Some("Input must be valid UTF-8 text for segment and element values")
491            }
492            Self::InvalidReleaseSequence { .. } => {
493                Some("Release character must escape one following byte; trailing '?' is invalid")
494            }
495            Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
496            Self::InvalidUna => Some(
497                "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
498            ),
499            Self::MissingRequiredElement { .. } => {
500                Some("Provide all mandatory elements for the segment per directory rules")
501            }
502            Self::MissingRequiredComponent { .. } => Some(
503                "Provide all mandatory components for the composite element per directory rules",
504            ),
505            Self::InvalidSegmentForMessage { .. } => {
506                Some("Remove unsupported segment or switch to the correct message type")
507            }
508            Self::InvalidElementCount { .. } => {
509                Some("Adjust the segment element count to the allowed min/max range")
510            }
511            Self::InvalidComponentCount { .. } => {
512                Some("Fix composite element arity to match the expected component count")
513            }
514            Self::InvalidCodeValue { .. } => {
515                Some("Use a value from the referenced code list for this element")
516            }
517            Self::MissingSegment { .. } => {
518                Some("Insert the required segment at the expected position")
519            }
520            Self::QualifierMismatch { .. } => {
521                Some("Set the segment qualifier to the expected value")
522            }
523            Self::ConditionalRequirementNotMet { .. } => {
524                Some("When the condition is met, include the conditionally required element")
525            }
526            Self::SegmentTooLong { limit, .. } => {
527                let _ = limit; // used in the error message; hint is generic
528                Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
529            }
530            Self::InvalidEventSequence { .. } => {
531                Some("Emit StartSegment before Element, and Element before ComponentElement")
532            }
533            Self::InvalidElementPosition => Some(
534                "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
535            ),
536            Self::IncompatibleReleaseScopes { .. } => Some(
537                "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
538            ),
539            Self::InvalidFieldValue { .. } => Some(
540                "Correct the field value to match the expected format or range for this element",
541            ),
542            Self::UnexpectedDataToken { .. } => Some(
543                "A data element appeared before any segment tag; check for partial writes or encoding corruption",
544            ),
545            Self::FunctionalGroupNotSupported { .. } => Some(
546                "Strip UNG/UNE segments before calling validate_envelope, or process the interchange as raw segments",
547            ),
548            Self::ValidationErrors { .. }
549            | Self::MessageCountMismatch { .. }
550            | Self::SegmentCountMismatch { .. }
551            | Self::UnexpectedMessageType { .. }
552            | Self::InterchangeTooLarge { .. }
553            | Self::InvalidUtf8
554            | Self::Io(_) => None,
555        }
556    }
557}
558
559#[cfg(feature = "diagnostics")]
560#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
561impl miette::Diagnostic for EdifactError {
562    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
563        Some(Box::new(self.stable_code()))
564    }
565
566    fn severity(&self) -> Option<miette::Severity> {
567        match self {
568            Self::InvalidCodeValue { .. }
569            | Self::InvalidComponentCount { .. }
570            | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
571            _ => Some(miette::Severity::Error),
572        }
573    }
574
575    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
576        match self {
577            // Static text — no allocation needed.
578            Self::InvalidUna => Some(Box::new(
579                "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
580            )),
581            Self::InvalidUtf8 => Some(Box::new(
582                "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
583            )),
584            // Dynamic help text.
585            Self::UnexpectedEof { offset } => Some(Box::new(format!(
586                "Check that all segments are terminated with the segment terminator (usually '). \
587                 Reached end at offset {offset}",
588            ))),
589            Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
590                "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
591                 Check UNA configuration",
592            ))),
593            Self::InvalidText { offset } => Some(Box::new(format!(
594                "The byte sequence at offset {offset} contains invalid UTF-8. \
595                 Ensure input is valid UTF-8",
596            ))),
597            Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
598                "Release character at offset {offset} is dangling. \
599                 Ensure '?' is followed by an escaped byte",
600            ))),
601            Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
602                "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
603                 Check the UNZ message count",
604            ))),
605            Self::SegmentCountMismatch {
606                expected,
607                actual,
608                message_ref,
609            } => Some(Box::new(format!(
610                "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
611                 Check the UNT segment count",
612            ))),
613            Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
614                "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
615            ))),
616            Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
617                "Segment {tag} requires element at index {element_index}",
618            ))),
619            Self::MissingRequiredComponent {
620                tag,
621                element_index,
622                component_index,
623            } => Some(Box::new(format!(
624                "Segment {tag} element {element_index} requires component at index {component_index}",
625            ))),
626            Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
627            Self::InvalidSegmentForMessage {
628                tag, message_type, ..
629            } => Some(Box::new(format!(
630                "Segment {tag} should not appear in a {message_type} message. \
631                 Check the directory definition",
632            ))),
633            Self::InvalidElementCount {
634                tag,
635                min,
636                max,
637                actual,
638                ..
639            } => Some(Box::new(format!(
640                "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
641                 Check segment structure",
642            ))),
643            Self::InvalidComponentCount {
644                tag,
645                element_index,
646                expected,
647                actual,
648                ..
649            } => Some(Box::new(format!(
650                "In segment {tag}, element {element_index} should have {expected} components \
651                     but has {actual}. Check element structure",
652            ))),
653            Self::InvalidCodeValue {
654                tag,
655                element_index,
656                value,
657                code_list,
658                ..
659            } => Some(Box::new(format!(
660                "Value '{value}' in segment {tag} element {element_index} is not in the \
661                     {code_list} code list. Check the directory for valid codes",
662            ))),
663            Self::MissingSegment {
664                tag,
665                expected_position,
666            } => Some(Box::new(format!(
667                "Segment {tag} is required at position {expected_position} but is missing. \
668                 Add this segment to the message",
669            ))),
670            Self::QualifierMismatch {
671                tag,
672                actual,
673                expected,
674                ..
675            } => Some(Box::new(format!(
676                "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
677                 Check the segment's first component",
678            ))),
679            Self::ConditionalRequirementNotMet {
680                tag,
681                element_index,
682                condition,
683                ..
684            } => Some(Box::new(format!(
685                "In segment {tag}, element {element_index} is conditionally required when: \
686                     {condition}. Check if the condition is met",
687            ))),
688            Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
689                "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
690                 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
691                 or verify the input for a missing segment terminator",
692            ))),
693            Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
694                "No handler was registered for message type '{message_type}'. \
695                 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
696            ))),
697            Self::InterchangeTooLarge { count } => Some(Box::new(format!(
698                "Interchange contains {count} items which exceeds the u32::MAX limit. \
699                 This is an extremely unusual input; verify the message is not corrupted.",
700            ))),
701            Self::InvalidEventSequence { message } => Some(Box::new(format!(
702                "Event sequence violation: {message}. \
703                 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
704            ))),
705            Self::InvalidElementPosition => Some(Box::new(
706                "Element positions must be >= 1 (one-based). \
707                 Ensure no OwnedElementRef is constructed with position == 0",
708            )),
709            Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
710                "Release scope {current:?} and {incoming:?} are incompatible. \
711                 Only compose ProfileRulePack values that share the same release scope, \
712                 or where at most one carries a release scope",
713            ))),
714            Self::InvalidFieldValue {
715                tag,
716                element_index,
717                value,
718            } => Some(Box::new(format!(
719                "Segment {tag} element {element_index} has invalid value '{value}'. \
720                 Check the expected format or range for this field",
721            ))),
722            Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
723                "Data element at offset {offset} appeared before any segment tag. \
724                 Check for partial writes or encoding corruption",
725            ))),
726            Self::FunctionalGroupNotSupported { offset } => Some(Box::new(format!(
727                "Functional group segment (UNG/UNE) found at offset {offset}. \
728                 Strip UNG/UNE wrappers before calling validate_envelope",
729            ))),
730            Self::ValidationErrors { error_count, .. } => Some(Box::new(format!(
731                "Validation found {error_count} error(s). Inspect the ValidationReport for details",
732            ))),
733        }
734    }
735}
736
737// ── validation report ─────────────────────────────────────────────────────────
738
739/// Priority level for a validation error or warning.
740///
741/// Marked `#[non_exhaustive]` so that adding new severity levels in future
742/// releases is not a breaking change for downstream match arms.
743#[non_exhaustive]
744#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
745#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
746pub enum ValidationSeverity {
747    /// Structural parse failure; processing cannot continue.
748    Critical,
749    /// Structural validation failed; message is invalid.
750    Error,
751    /// Data validation warning (e.g., code-list mismatch); message may be usable.
752    Warning,
753    /// Informational note; message is valid but noteworthy.
754    Info,
755}
756
757impl ValidationSeverity {
758    /// Return a lowercase ASCII string for this severity level.
759    ///
760    /// Stable for the four known variants.  Because the enum is
761    /// `#[non_exhaustive]`, new variants added in future releases are
762    /// handled by a catch-all arm that returns `"unknown"` so that
763    /// existing code keeps compiling and serialising gracefully.
764    #[must_use]
765    pub fn as_str(self) -> &'static str {
766        match self {
767            Self::Critical => "critical",
768            Self::Error => "error",
769            Self::Warning => "warning",
770            Self::Info => "info",
771            #[allow(unreachable_patterns)]
772            _ => "unknown",
773        }
774    }
775
776    /// Return a numeric priority for this severity level.
777    ///
778    /// Higher values indicate higher severity: `Critical = 3`, `Error = 2`,
779    /// `Warning = 1`, `Info = 0`.
780    #[must_use]
781    pub fn numeric_level(self) -> u8 {
782        match self {
783            Self::Info => 0,
784            Self::Warning => 1,
785            Self::Error => 2,
786            Self::Critical => 3,
787        }
788    }
789}
790
791impl std::fmt::Display for ValidationSeverity {
792    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793        f.write_str(self.as_str())
794    }
795}
796
797/// A structured validation issue.
798///
799/// Marked `#[non_exhaustive]` so that new diagnostic fields (e.g. `segment_group`)
800/// can be added in future releases without breaking downstream code that constructs
801/// issues via struct literals.  Always use [`ValidationIssue::new`] + builder
802/// methods (`with_*`) rather than constructing directly.
803#[derive(Debug, Clone, PartialEq)]
804#[non_exhaustive]
805#[cfg_attr(feature = "serde", derive(serde::Serialize))]
806pub struct ValidationIssue {
807    /// Stable error code, if known.
808    pub error_code: Option<&'static str>,
809    /// The severity of this issue.
810    pub severity: ValidationSeverity,
811    /// The error or warning message.
812    pub message: String,
813    /// Byte offset in the source (if available).
814    pub offset: Option<usize>,
815    /// Segment tag involved (if known).
816    pub segment_tag: Option<String>,
817    /// Profile/MIG rule identifier, if applicable.
818    pub rule_id: Option<String>,
819    /// Element index (0-based), if known.
820    ///
821    /// `u8` is sufficient: EDIFACT segments have at most 99 data elements per
822    /// the UN/EDIFACT standard, so an index fits comfortably in one byte.
823    pub element_index: Option<u8>,
824    /// Component index (0-based), if known.
825    ///
826    /// `u8` is sufficient: composite data elements have at most 99 components
827    /// per the UN/EDIFACT standard.
828    pub component_index: Option<u8>,
829    /// Zero-based occurrence index among segments with the same tag in the message.
830    ///
831    /// When multiple segments share the same tag (e.g. repeated `DTM` lines),
832    /// this field indicates which occurrence (0 = first) was the source of
833    /// this issue.  `None` when occurrence tracking is not available for this rule.
834    pub segment_occurrence: Option<u16>,
835    /// Message reference (`UNH` element 0, DE 0062) that this issue belongs to.
836    ///
837    /// Populated automatically when the context was built with
838    /// `ValidationContextBuilder::with_message_ref`.  Useful in batch processing
839    /// where many messages are validated and issues from different messages must
840    /// be correlated back to the originating `UNH`/`UNT` envelope.
841    pub message_ref: Option<String>,
842    /// Suggested remediation (if available).
843    pub suggestion: Option<String>,
844    /// Segment group (e.g. `"SG6"`) in which the issue occurred, if known.
845    ///
846    /// Populated by group-aware rule functions when they evaluate sub-slices of a
847    /// [`crate::group::SegmentGroupIndexed`] tree.  `None` for flat-segment rules
848    /// that do not have group context.
849    pub segment_group: Option<Arc<str>>,
850}
851
852impl ValidationIssue {
853    /// Create a new validation issue.
854    pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
855        Self {
856            error_code: None,
857            severity,
858            message: message.into(),
859            offset: None,
860            segment_tag: None,
861            rule_id: None,
862            element_index: None,
863            component_index: None,
864            segment_occurrence: None,
865            message_ref: None,
866            suggestion: None,
867            segment_group: None,
868        }
869    }
870
871    /// Set stable error code metadata.
872    pub fn with_error_code(mut self, code: &'static str) -> Self {
873        self.error_code = Some(code);
874        self
875    }
876
877    /// Set the offset for this issue.
878    pub fn with_offset(mut self, offset: usize) -> Self {
879        self.offset = Some(offset);
880        self
881    }
882
883    /// Set the segment tag for this issue.
884    pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
885        self.segment_tag = Some(tag.into());
886        self
887    }
888
889    /// Set the profile/MIG rule identifier for this issue.
890    pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
891        self.rule_id = Some(rule_id.into());
892        self
893    }
894
895    /// Set the element index (0-based) for this issue.
896    pub fn with_element_index(mut self, element_index: u8) -> Self {
897        self.element_index = Some(element_index);
898        self
899    }
900
901    /// Set the component index (0-based) for this issue.
902    pub fn with_component_index(mut self, component_index: u8) -> Self {
903        self.component_index = Some(component_index);
904        self
905    }
906
907    /// Set a suggestion for resolving this issue.
908    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
909        self.suggestion = Some(suggestion.into());
910        self
911    }
912
913    /// Set the zero-based occurrence index for this issue.
914    ///
915    /// Use this when the same segment tag appears multiple times in a message
916    /// and you want to identify which occurrence is affected.
917    pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
918        self.segment_occurrence = Some(occurrence);
919        self
920    }
921
922    /// Set the message reference (`UNH` element 0) for this issue.
923    ///
924    /// Use this to correlate an issue back to a specific message in a
925    /// multi-message interchange.
926    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
927        self.message_ref = Some(message_ref.into());
928        self
929    }
930
931    /// Set the segment group (e.g. `"SG6"`) in which this issue occurred.
932    ///
933    /// Use this from group-aware rule functions that evaluate a sub-slice of a
934    /// [`crate::group::SegmentGroupIndexed`] tree so that consumers can identify
935    /// the exact group occurrence without re-reading the raw message.
936    pub fn with_segment_group(mut self, group: impl Into<Arc<str>>) -> Self {
937        self.segment_group = Some(group.into());
938        self
939    }
940
941    /// Short label for the severity level, suitable for display.
942    #[must_use]
943    pub fn severity_label(&self) -> &'static str {
944        match self.severity {
945            ValidationSeverity::Critical => "CRITICAL",
946            ValidationSeverity::Error => "ERROR",
947            ValidationSeverity::Warning => "WARNING",
948            ValidationSeverity::Info => "INFO",
949        }
950    }
951
952    // ── Getters ───────────────────────────────────────────────────────────────
953
954    /// Stable error code, if available.
955    #[must_use]
956    #[inline]
957    pub fn error_code(&self) -> Option<&'static str> {
958        self.error_code
959    }
960
961    /// Byte offset in the source, if available.
962    #[must_use]
963    #[inline]
964    pub fn offset(&self) -> Option<usize> {
965        self.offset
966    }
967
968    /// Segment tag involved in this issue, if known.
969    #[must_use]
970    #[inline]
971    pub fn segment_tag(&self) -> Option<&str> {
972        self.segment_tag.as_deref()
973    }
974
975    /// Profile/MIG rule identifier, if applicable.
976    #[must_use]
977    #[inline]
978    pub fn rule_id(&self) -> Option<&str> {
979        self.rule_id.as_deref()
980    }
981
982    /// Zero-based element index, if known.
983    #[must_use]
984    #[inline]
985    pub fn element_index(&self) -> Option<u8> {
986        self.element_index
987    }
988
989    /// Zero-based component index, if known.
990    #[must_use]
991    #[inline]
992    pub fn component_index(&self) -> Option<u8> {
993        self.component_index
994    }
995
996    /// Zero-based occurrence index among same-tag segments, if known.
997    #[must_use]
998    #[inline]
999    pub fn segment_occurrence(&self) -> Option<u16> {
1000        self.segment_occurrence
1001    }
1002
1003    /// Message reference (`UNH` element 0), if set.
1004    #[must_use]
1005    #[inline]
1006    pub fn message_ref(&self) -> Option<&str> {
1007        self.message_ref.as_deref()
1008    }
1009
1010    /// Suggested remediation, if available.
1011    #[must_use]
1012    #[inline]
1013    pub fn suggestion(&self) -> Option<&str> {
1014        self.suggestion.as_deref()
1015    }
1016
1017    /// Segment group (e.g. `"SG6"`) in which the issue occurred, if known.
1018    #[must_use]
1019    #[inline]
1020    pub fn segment_group(&self) -> Option<&str> {
1021        self.segment_group.as_deref()
1022    }
1023}
1024
1025impl std::fmt::Display for ValidationIssue {
1026    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027        write!(f, "[{}] {}", self.severity_label(), self.message)
1028    }
1029}
1030
1031impl std::error::Error for ValidationIssue {}
1032
1033/// A collection of validation results: errors, warnings, and info.
1034///
1035/// Enables batch validation where all issues are collected instead of failing on the first error.
1036#[derive(Debug, Clone, Default, PartialEq)]
1037#[cfg_attr(feature = "serde", derive(serde::Serialize))]
1038pub struct ValidationReport {
1039    /// Critical and error-level issues.
1040    pub(crate) errors: Vec<ValidationIssue>,
1041    /// Warning-level issues.
1042    pub(crate) warnings: Vec<ValidationIssue>,
1043    /// Informational notes.
1044    pub(crate) infos: Vec<ValidationIssue>,
1045}
1046
1047impl ValidationReport {
1048    /// Returns all error-level [`ValidationIssue`]s in this report.
1049    pub fn errors(&self) -> &[ValidationIssue] {
1050        &self.errors
1051    }
1052
1053    /// Returns all error-level [`ValidationIssue`]s mutably.
1054    pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
1055        &mut self.errors
1056    }
1057
1058    /// Returns all warning-level [`ValidationIssue`]s in this report.
1059    pub fn warnings(&self) -> &[ValidationIssue] {
1060        &self.warnings
1061    }
1062
1063    /// Returns all warning-level [`ValidationIssue`]s mutably.
1064    pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
1065        &mut self.warnings
1066    }
1067
1068    /// Returns all informational [`ValidationIssue`]s in this report.
1069    pub fn infos(&self) -> &[ValidationIssue] {
1070        &self.infos
1071    }
1072
1073    /// Returns all informational [`ValidationIssue`]s mutably.
1074    pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
1075        &mut self.infos
1076    }
1077    /// Add an error to the report.
1078    pub fn add_error(&mut self, issue: ValidationIssue) {
1079        self.errors.push(issue);
1080    }
1081
1082    /// Add a warning to the report.
1083    pub fn add_warning(&mut self, issue: ValidationIssue) {
1084        self.warnings.push(issue);
1085    }
1086
1087    /// Add an info message to the report.
1088    pub fn add_info(&mut self, issue: ValidationIssue) {
1089        self.infos.push(issue);
1090    }
1091
1092    /// Check if the report has any errors.
1093    pub fn has_errors(&self) -> bool {
1094        !self.errors().is_empty()
1095    }
1096
1097    /// Check if the report has any warnings.
1098    pub fn has_warnings(&self) -> bool {
1099        !self.warnings().is_empty()
1100    }
1101
1102    /// Get the total count of all issues.
1103    pub fn total_issues(&self) -> usize {
1104        self.errors().len() + self.warnings().len() + self.infos().len()
1105    }
1106
1107    /// Check if the validation passed (no errors, but may have warnings).
1108    pub fn is_valid(&self) -> bool {
1109        self.errors().is_empty()
1110    }
1111
1112    /// Convert to a `Result`.
1113    ///
1114    /// Returns `Ok(self)` when there are no errors.  Returns `Err(self)` when
1115    /// there is at least one error-level issue, **preserving warnings and infos**
1116    /// in the `Err` variant so callers can inspect the full report.
1117    pub fn result(self) -> Result<Self, Self> {
1118        if self.is_valid() { Ok(self) } else { Err(self) }
1119    }
1120
1121    /// Iterate over all issues in severity buckets: errors, warnings, then infos.
1122    pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
1123        self.errors()
1124            .iter()
1125            .chain(self.warnings().iter())
1126            .chain(self.infos().iter())
1127    }
1128
1129    /// Return `true` if the report contains any issues (errors, warnings, or infos).
1130    pub fn has_any_issues(&self) -> bool {
1131        !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
1132    }
1133
1134    /// Drain all issues from `other` into `self`.
1135    ///
1136    /// Issues are appended in severity order: errors, warnings, infos.
1137    /// `other` is left empty after this call.
1138    pub fn merge(&mut self, mut other: ValidationReport) {
1139        self.errors.append(&mut other.errors);
1140        self.warnings.append(&mut other.warnings);
1141        self.infos.append(&mut other.infos);
1142    }
1143
1144    /// Iterate over all issues matching an exact profile/MIG rule identifier.
1145    ///
1146    /// Searches errors, warnings, and infos in that order.  Returns a lazy
1147    /// iterator; collect into `Vec` if you need random access.
1148    pub fn issues_for_rule_id<'a>(
1149        &'a self,
1150        rule_id: &'a str,
1151    ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
1152        self.iter_issues()
1153            .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
1154    }
1155
1156    /// Return a cloned report filtered by `pred`.
1157    fn filter_report<F>(&self, pred: F) -> Self
1158    where
1159        F: Fn(&ValidationIssue) -> bool,
1160    {
1161        Self {
1162            errors: self.errors().iter().filter(|i| pred(i)).cloned().collect(),
1163            warnings: self
1164                .warnings()
1165                .iter()
1166                .filter(|i| pred(i))
1167                .cloned()
1168                .collect(),
1169            infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
1170        }
1171    }
1172
1173    /// Return a cloned report containing only issues with an exact rule identifier.
1174    pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
1175        self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
1176    }
1177
1178    /// Return a cloned report containing only issues whose rule identifier starts with `prefix`.
1179    pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
1180        self.filter_report(|issue| {
1181            issue
1182                .rule_id
1183                .as_deref()
1184                .is_some_and(|id| id.starts_with(prefix))
1185        })
1186    }
1187
1188    /// Return a cloned report containing only issues that reference `segment_tag`.
1189    ///
1190    /// Issues whose `segment_tag` field does not match are dropped; the severity
1191    /// buckets (errors / warnings / infos) are preserved.
1192    ///
1193    /// # Example
1194    ///
1195    /// ```rust
1196    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
1197    ///
1198    /// let mut report = ValidationReport::default();
1199    /// report.add_error(
1200    ///     ValidationIssue::new(ValidationSeverity::Error, "BGM missing")
1201    ///         .with_segment("BGM"),
1202    /// );
1203    /// report.add_error(
1204    ///     ValidationIssue::new(ValidationSeverity::Error, "NAD missing")
1205    ///         .with_segment("NAD"),
1206    /// );
1207    /// let bgm_issues = report.for_segment("BGM");
1208    /// assert_eq!(bgm_issues.errors().len(), 1);
1209    /// assert_eq!(bgm_issues.errors()[0].segment_tag.as_deref(), Some("BGM"));
1210    /// ```
1211    pub fn for_segment(&self, segment_tag: &str) -> Self {
1212        self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
1213    }
1214
1215    /// Return a deterministic, stable text representation for snapshots and logs.
1216    pub fn render_deterministic(&self) -> String {
1217        fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
1218            let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
1219            refs.sort_by(|left, right| {
1220                left.offset
1221                    .unwrap_or(usize::MAX)
1222                    .cmp(&right.offset.unwrap_or(usize::MAX))
1223                    .then_with(|| {
1224                        left.segment_tag
1225                            .as_deref()
1226                            .unwrap_or("")
1227                            .cmp(right.segment_tag.as_deref().unwrap_or(""))
1228                    })
1229                    .then_with(|| {
1230                        left.rule_id
1231                            .as_deref()
1232                            .unwrap_or("")
1233                            .cmp(right.rule_id.as_deref().unwrap_or(""))
1234                    })
1235                    .then_with(|| {
1236                        left.element_index
1237                            .unwrap_or(u8::MAX)
1238                            .cmp(&right.element_index.unwrap_or(u8::MAX))
1239                    })
1240                    .then_with(|| {
1241                        left.component_index
1242                            .unwrap_or(u8::MAX)
1243                            .cmp(&right.component_index.unwrap_or(u8::MAX))
1244                    })
1245                    .then_with(|| {
1246                        left.error_code
1247                            .unwrap_or("")
1248                            .cmp(right.error_code.unwrap_or(""))
1249                    })
1250                    .then_with(|| left.message.cmp(&right.message))
1251            });
1252            refs
1253        }
1254
1255        fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
1256            use std::fmt::Write as _;
1257            out.push_str("    - ");
1258            out.push_str(&issue.message);
1259            if let Some(code) = issue.error_code {
1260                out.push_str(" [");
1261                out.push_str(code);
1262                out.push(']');
1263            }
1264            if let Some(seg) = &issue.segment_tag {
1265                out.push_str(" [segment=");
1266                out.push_str(seg);
1267                out.push(']');
1268            }
1269            if let Some(rule_id) = &issue.rule_id {
1270                out.push_str(" [rule=");
1271                out.push_str(rule_id);
1272                out.push(']');
1273            }
1274            if let Some(element_index) = issue.element_index {
1275                write!(out, " [element={element_index}]").ok();
1276            }
1277            if let Some(component_index) = issue.component_index {
1278                write!(out, " [component={component_index}]").ok();
1279            }
1280            if let Some(offset) = issue.offset {
1281                write!(out, " [offset={offset}]").ok();
1282            }
1283            if let Some(suggestion) = &issue.suggestion {
1284                out.push_str(" [hint=");
1285                out.push_str(suggestion);
1286                out.push(']');
1287            }
1288        }
1289
1290        use std::fmt::Write as _;
1291        let mut out = String::from("Validation Report:");
1292        let errors = sorted_refs(self.errors());
1293        let warnings = sorted_refs(self.warnings());
1294        let infos = sorted_refs(self.infos());
1295
1296        if !errors.is_empty() {
1297            write!(out, "\n  Errors ({})", errors.len()).ok();
1298            for issue in &errors {
1299                out.push('\n');
1300                render_issue_line(&mut out, issue);
1301            }
1302        }
1303        if !warnings.is_empty() {
1304            write!(out, "\n  Warnings ({})", warnings.len()).ok();
1305            for issue in &warnings {
1306                out.push('\n');
1307                render_issue_line(&mut out, issue);
1308            }
1309        }
1310        if !infos.is_empty() {
1311            write!(out, "\n  Info ({})", infos.len()).ok();
1312            for issue in &infos {
1313                out.push('\n');
1314                render_issue_line(&mut out, issue);
1315            }
1316        }
1317
1318        out
1319    }
1320}
1321
1322#[cfg(feature = "diagnostics")]
1323impl miette::Diagnostic for ValidationReport {
1324    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1325        Some(Box::new("VALIDATION"))
1326    }
1327
1328    fn severity(&self) -> Option<miette::Severity> {
1329        if self.has_errors() {
1330            Some(miette::Severity::Error)
1331        } else if self.has_warnings() {
1332            Some(miette::Severity::Warning)
1333        } else {
1334            Some(miette::Severity::Advice)
1335        }
1336    }
1337
1338    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1339        let msg = format!(
1340            "Validation found {} error(s), {} warning(s), {} info(s)",
1341            self.errors().len(),
1342            self.warnings().len(),
1343            self.infos().len()
1344        );
1345        Some(Box::new(msg))
1346    }
1347}
1348
1349impl std::fmt::Display for ValidationReport {
1350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1351        write!(f, "{}", self.render_deterministic())
1352    }
1353}
1354
1355impl std::error::Error for ValidationReport {}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360
1361    #[test]
1362    fn validation_report_collects_errors() {
1363        let mut report = ValidationReport::default();
1364        report.add_error(
1365            ValidationIssue::new(ValidationSeverity::Error, "Test error")
1366                .with_segment("BGM")
1367                .with_offset(42),
1368        );
1369        report.add_warning(ValidationIssue::new(
1370            ValidationSeverity::Warning,
1371            "Test warning",
1372        ));
1373
1374        assert!(report.has_errors());
1375        assert!(report.has_warnings());
1376        assert_eq!(report.total_issues(), 2);
1377        assert!(!report.is_valid());
1378    }
1379
1380    #[test]
1381    fn validation_report_result_conversion() {
1382        let mut report = ValidationReport::default();
1383        report.add_error(ValidationIssue::new(
1384            ValidationSeverity::Error,
1385            "Critical issue",
1386        ));
1387
1388        let result = report.result();
1389        assert!(result.is_err());
1390    }
1391
1392    #[test]
1393    fn validation_report_passes_when_no_errors() {
1394        let mut report = ValidationReport::default();
1395        report.add_warning(ValidationIssue::new(
1396            ValidationSeverity::Warning,
1397            "Just a warning",
1398        ));
1399
1400        assert!(report.is_valid());
1401        assert!(report.result().is_ok());
1402    }
1403
1404    #[test]
1405    fn validation_issue_builder() {
1406        let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1407            .with_error_code("E013")
1408            .with_offset(100)
1409            .with_segment("NAD")
1410            .with_rule_id("DEMO-P001")
1411            .with_element_index(1)
1412            .with_component_index(2)
1413            .with_suggestion("Check element count");
1414
1415        assert_eq!(issue.error_code, Some("E013"));
1416        assert_eq!(issue.message, "test message");
1417        assert_eq!(issue.offset, Some(100));
1418        assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1419        assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1420        assert_eq!(issue.element_index, Some(1));
1421        assert_eq!(issue.component_index, Some(2));
1422        assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1423    }
1424
1425    #[test]
1426    fn validation_report_display() {
1427        let mut report = ValidationReport::default();
1428        report.add_error(
1429            ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1430                .with_error_code("E011")
1431                .with_offset(8),
1432        );
1433        report.add_warning(ValidationIssue::new(
1434            ValidationSeverity::Warning,
1435            "Warning 1",
1436        ));
1437        report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1438
1439        let display_str = format!("{}", report);
1440        assert!(display_str.contains("Errors (1)"));
1441        assert!(display_str.contains("Warnings (1)"));
1442        assert!(display_str.contains("Info (1)"));
1443        assert!(display_str.contains("[E011]"));
1444    }
1445
1446    #[test]
1447    fn validation_report_render_is_deterministic() {
1448        let mut report = ValidationReport::default();
1449        report.add_error(
1450            ValidationIssue::new(ValidationSeverity::Error, "later")
1451                .with_segment("BGM")
1452                .with_offset(20),
1453        );
1454        report.add_error(
1455            ValidationIssue::new(ValidationSeverity::Error, "earlier")
1456                .with_segment("UNH")
1457                .with_offset(1),
1458        );
1459
1460        let rendered = report.render_deterministic();
1461        let first = rendered.find("earlier").expect("missing first issue");
1462        let second = rendered.find("later").expect("missing second issue");
1463        assert!(first < second, "expected deterministic sort by offset");
1464    }
1465
1466    #[test]
1467    fn recovery_hint_exists_for_common_malformed_cases() {
1468        let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1469        assert!(err.recovery_hint().is_some());
1470
1471        let err = EdifactError::InvalidCodeValue {
1472            tag: "BGM".to_owned(),
1473            element_index: 0,
1474            value: "X".to_owned(),
1475            code_list: "1001".to_owned(),
1476            offset: 0,
1477            suggestion: None,
1478        };
1479        assert!(err.recovery_hint().is_some());
1480    }
1481
1482    #[test]
1483    fn validation_report_can_filter_by_rule_id() {
1484        let mut report = ValidationReport::default();
1485        report.add_error(
1486            ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1487                .with_rule_id("ORDERS-P001"),
1488        );
1489        report.add_warning(
1490            ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1491                .with_rule_id("INVOIC-P001"),
1492        );
1493        report.add_info(
1494            ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1495                .with_rule_id("ORDERS-P002"),
1496        );
1497
1498        let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1499        assert_eq!(only_orders_block.errors().len(), 1);
1500        assert!(only_orders_block.warnings().is_empty());
1501        assert!(only_orders_block.infos().is_empty());
1502
1503        let orders_family = report.filter_by_rule_prefix("ORDERS-");
1504        assert_eq!(orders_family.total_issues(), 2);
1505        assert!(orders_family.has_errors());
1506        assert!(!orders_family.has_warnings());
1507
1508        let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1509        assert_eq!(exact.len(), 1);
1510        assert_eq!(exact[0].message, "invoic policy warning");
1511    }
1512}