Skip to main content

edifact_rs/
error.rs

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