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