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 five active characters (`element_sep`,
135    /// `component_sep`, `decimal_mark`, `release_char`, and `segment_term`) must
136    /// all be mutually distinct and in the printable ASCII range `0x21–0x7E`.
137    /// Only the **repetition separator** (UNA byte 7) is not validated by this
138    /// check — it is read and stored but excluded from the uniqueness and
139    /// printability assertions.
140    #[error("invalid UNA service string advice")]
141    InvalidUna,
142
143    /// Missing required element in a segment.
144    ///
145    /// Certain segments require specific elements to be present. This error indicates
146    /// a mandatory element was not found.
147    #[error("missing required element {element_index} in segment {tag}")]
148    MissingRequiredElement {
149        /// Segment tag containing the missing element.
150        tag: String,
151        /// Zero-based required element index.
152        element_index: usize,
153    },
154
155    /// Missing required component in a composite element.
156    ///
157    /// The element is present, but the required component at the given index is absent or empty.
158    #[error(
159        "missing required component {component_index} in element {element_index} of segment {tag}"
160    )]
161    MissingRequiredComponent {
162        /// Segment tag containing the composite element.
163        tag: String,
164        /// Zero-based element index of the composite.
165        element_index: usize,
166        /// Zero-based component index that was absent.
167        component_index: usize,
168    },
169
170    /// Output serialization produced invalid UTF-8.
171    ///
172    /// This is an internal consistency error; the writer should never produce non-UTF-8 output.
173    /// If this occurs, it indicates a bug in the serialization logic.
174    #[error("serialized output contains invalid UTF-8")]
175    InvalidUtf8,
176
177    /// I/O error from reading or writing.
178    #[error(transparent)]
179    Io(#[from] IoError),
180
181    // ── validation variants (E010–E020) ────────────────────────────────────
182    /// Segment is not valid for the current message type.
183    ///
184    /// Structural validation found a segment that should not appear in this message.
185    #[error("segment {tag} is not valid for message type {message_type}")]
186    InvalidSegmentForMessage {
187        /// Segment tag that is not allowed for the message type.
188        tag: String,
189        /// Message type used for structural validation.
190        message_type: String,
191        /// Segment tag byte offset.
192        offset: usize,
193    },
194
195    /// Element count in segment exceeds or falls short of directory definition.
196    ///
197    /// Validation against directory metadata found an element count mismatch.
198    #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
199    InvalidElementCount {
200        /// Segment tag with wrong arity.
201        tag: String,
202        /// Minimum allowed element count.
203        min: usize,
204        /// Maximum allowed element count.
205        max: usize,
206        /// Actual element count found.
207        actual: usize,
208        /// Segment start byte offset.
209        offset: usize,
210    },
211
212    /// Component count in a composite element is invalid.
213    ///
214    /// A composite data element does not have the expected number of components.
215    #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
216    InvalidComponentCount {
217        /// Segment tag containing the composite.
218        tag: String,
219        /// Zero-based element index of the composite.
220        element_index: usize,
221        /// Expected component count.
222        expected: u8,
223        /// Actual component count found.
224        actual: u8,
225        /// Segment start byte offset.
226        offset: usize,
227    },
228
229    /// Code-list value is not valid.
230    ///
231    /// The value appears in a field that should contain a code from a specific code list,
232    /// but the value is not in that code list.
233    #[error(
234        "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
235    )]
236    InvalidCodeValue {
237        /// Segment tag containing the invalid value.
238        tag: String,
239        /// Zero-based element index containing the invalid code.
240        element_index: usize,
241        /// Invalid code value observed.
242        value: String,
243        /// Data element code list identifier.
244        code_list: String,
245        /// Segment start byte offset.
246        offset: usize,
247        /// Optional remediation suggestion from the code-list lookup function.
248        suggestion: Option<&'static str>,
249    },
250
251    /// A required segment is missing from the message.
252    ///
253    /// Structural validation found that a mandatory segment is absent.
254    #[error("required segment {tag} is missing from message (position {expected_position})")]
255    MissingSegment {
256        /// Missing segment tag.
257        tag: String,
258        /// Human-readable position hint.
259        expected_position: String,
260    },
261
262    /// Qualifier does not match expected value for segment.
263    ///
264    /// A qualified segment (e.g., NAD+MS) has a qualifier that does not match expected.
265    #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
266    QualifierMismatch {
267        /// Segment tag whose qualifier mismatched.
268        tag: String,
269        /// Actual qualifier found.
270        actual: String,
271        /// Expected qualifier value.
272        expected: String,
273        /// Segment start byte offset.
274        offset: usize,
275    },
276
277    /// Conditional requirement not met.
278    ///
279    /// A segment or element is conditionally required based on another element's value,
280    /// but the condition was not satisfied.
281    #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
282    ConditionalRequirementNotMet {
283        /// Segment tag that violated a conditional rule.
284        tag: String,
285        /// Zero-based element index governed by the condition.
286        element_index: usize,
287        /// Condition text describing the rule.
288        condition: String,
289        /// Segment start byte offset.
290        offset: usize,
291    },
292
293    /// Validation failed and the full [`ValidationReport`] is preserved.
294    ///
295    /// Returned by validation helpers when errors are found.  Provides programmatic
296    /// access to all issues, warnings, and infos.
297    ///
298    /// # Example
299    ///
300    /// ```rust,ignore
301    /// match my_fn() {
302    ///     Err(EdifactError::ValidationErrors { report, .. }) => {
303    ///         for issue in report.errors() {
304    ///             eprintln!("{}", issue);
305    ///         }
306    ///     }
307    ///     other => { /* ... */ }
308    /// }
309    /// ```
310    #[error("validation failed with {error_count} error(s)")]
311    ValidationErrors {
312        /// Number of error-severity issues in the report.
313        error_count: usize,
314        /// Full report with all errors, warnings, and infos.
315        report: Box<ValidationReport>,
316    },
317
318    /// Segment exceeded the configured maximum byte length.
319    ///
320    /// Returned by reader-based parsers when an unterminated segment accumulates more
321    /// bytes than the configured `max_segment_bytes` limit in [`ReaderConfig`].  This
322    /// prevents resource exhaustion on adversarially crafted or truncated input that
323    /// never emits a segment terminator.
324    ///
325    /// [`ReaderConfig`]: crate::ReaderConfig
326    #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
327    SegmentTooLong {
328        /// Byte offset where the overlong segment started.
329        offset: usize,
330        /// Configured maximum segment byte length.
331        limit: usize,
332    },
333
334    /// No handler was registered in [`crate::MessageDispatch`] for this message type.
335    ///
336    /// Returned by [`crate::MessageDispatch::dispatch`] when the message-type
337    /// extracted from the `UNH` segment does not match any registered handler
338    /// and no fallback was configured.
339    #[error("no handler registered for message type {message_type}")]
340    UnexpectedMessageType {
341        /// The unhandled message type string from the `UNH` segment.
342        message_type: String,
343    },
344
345    /// An interchange or message contains more segments or messages than can be
346    /// represented in a `u32` counter (> 4 294 967 295).
347    ///
348    /// This is effectively unreachable in practice — no real-world EDIFACT
349    /// interchange has billions of segments — but the parser returns this error
350    /// rather than silently saturating or wrapping the counter.
351    #[error("interchange too large: count {count} exceeds u32::MAX")]
352    InterchangeTooLarge {
353        /// The count that could not be represented as `u32`.
354        count: u64,
355    },
356
357    /// An [`crate::EventEmitter`] received events in an invalid sequence.
358    ///
359    /// This indicates a programming error in the caller's serialization code:
360    /// for example, emitting an [`crate::EdifactEvent::Element`] without a prior
361    /// [`crate::EdifactEvent::StartSegment`], or emitting
362    /// [`crate::EdifactEvent::ComponentElement`] without a preceding
363    /// [`crate::EdifactEvent::Element`].
364    #[error("invalid event sequence: {message}")]
365    InvalidEventSequence {
366        /// Description of the protocol violation.
367        message: &'static str,
368    },
369
370    /// An [`crate::OwnedElementRef`] has `position = 0`, which is never valid.
371    ///
372    /// Element positions are one-based: position 1 refers to the first element
373    /// slot.  Position 0 is reserved and invalid.  Use [`crate::OwnedElementRef::try_new`]
374    /// to get a `Result` instead of a panic.
375    #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
376    InvalidElementPosition,
377
378    /// Two [`crate::ProfileRulePack`] values with incompatible release scopes were composed.
379    ///
380    /// When composing packs via [`crate::ProfileRulePack::extend_from`] or
381    /// [`crate::ProfileRulePack::merge_with_override`], both packs must either
382    /// share the same release scope or at most one may carry a scope.
383    #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
384    IncompatibleReleaseScopes {
385        /// Release scope of the pack being composed into.
386        current: String,
387        /// Release scope of the pack being composed in.
388        incoming: String,
389    },
390
391    /// A field value failed semantic validation (e.g. wrong format, out-of-range).
392    ///
393    /// Distinct from [`InvalidCodeValue`][Self::InvalidCodeValue] which is for
394    /// code-list membership checks.  Use this variant when a free-text or numeric
395    /// field contains a value that is structurally invalid for its purpose.
396    #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
397    InvalidFieldValue {
398        /// Segment tag that contains the invalid field.
399        tag: String,
400        /// Zero-based element index of the invalid field.
401        element_index: usize,
402        /// The invalid value that was observed.
403        value: String,
404    },
405
406    /// A data or component element token appeared before the first segment tag.
407    ///
408    /// EDIFACT syntax requires that every data element follows a segment tag.
409    /// A data element token encountered before any tag (e.g. after a stray
410    /// separator at the start of the stream) is a protocol violation.
411    ///
412    /// Unlike stray segment terminators (which are tolerated as blank lines),
413    /// stray data tokens indicate encoding corruption or a partial write.
414    #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
415    UnexpectedDataToken {
416        /// Byte offset of the stray token.
417        offset: usize,
418    },
419
420    /// The interchange syntax identifier (UNB DE 0001) is not a recognised ISO 9735-1 value.
421    ///
422    /// Valid syntax identifiers are: `UNOA`, `UNOB`, `UNOC`, `UNOD`, `UNOE`, `UNOF`, and
423    /// `KECA` (Korean EDI Centre A).  Any other value indicates a non-standard generator
424    /// or a corrupted UNB header.
425    #[error(
426        "unrecognised syntax identifier '{0}': expected UNOA/UNOB/UNOC/UNOD/UNOE/UNOF (or KECA)"
427    )]
428    UnrecognisedSyntaxIdentifier(String),
429}
430
431impl From<std::io::Error> for EdifactError {
432    fn from(e: std::io::Error) -> Self {
433        Self::Io(IoError(e))
434    }
435}
436
437impl EdifactError {
438    /// Stable diagnostic code for this error variant.
439    #[must_use]
440    pub const fn stable_code(&self) -> &'static str {
441        match self {
442            Self::UnexpectedEof { .. } => "E001",
443            Self::InvalidDelimiter { .. } => "E002",
444            Self::InvalidText { .. } => "E003",
445            Self::MessageCountMismatch { .. } => "E004",
446            Self::SegmentCountMismatch { .. } => "E005",
447            Self::InvalidSegmentTag(_) => "E006",
448            Self::InvalidUna => "E007",
449            Self::MissingRequiredElement { .. } => "E008",
450            Self::InvalidUtf8 => "E009",
451            Self::Io(_) => "E010",
452            Self::InvalidSegmentForMessage { .. } => "E011",
453            Self::InvalidElementCount { .. } => "E012",
454            Self::InvalidComponentCount { .. } => "E013",
455            Self::InvalidCodeValue { .. } => "E014",
456            Self::MissingSegment { .. } => "E015",
457            Self::QualifierMismatch { .. } => "E016",
458            Self::ConditionalRequirementNotMet { .. } => "E017",
459            // E018 is permanently retired (was ValidationFailed, removed in 0.8.0)
460            Self::InvalidReleaseSequence { .. } => "E019",
461            Self::SegmentTooLong { .. } => "E020",
462            Self::MissingRequiredComponent { .. } => "E021",
463            Self::UnexpectedMessageType { .. } => "E022",
464            Self::InterchangeTooLarge { .. } => "E023",
465            Self::InvalidEventSequence { .. } => "E024",
466            Self::InvalidElementPosition => "E025",
467            Self::IncompatibleReleaseScopes { .. } => "E026",
468            Self::InvalidFieldValue { .. } => "E027",
469            Self::UnexpectedDataToken { .. } => "E028",
470            // E029 is permanently retired (was FunctionalGroupNotSupported, removed when
471            // full UNG/UNE support was added — functional groups are now parsed natively)
472            Self::ValidationErrors { .. } => "E030",
473            Self::UnrecognisedSyntaxIdentifier(_) => "E031",
474        }
475    }
476
477    /// Stable recovery hint for common malformed input and validation cases.
478    #[must_use]
479    pub fn recovery_hint(&self) -> Option<&'static str> {
480        match self {
481            Self::UnexpectedEof { .. } => {
482                Some("Ensure every segment ends with the configured segment terminator")
483            }
484            Self::InvalidDelimiter { .. } => {
485                Some("Check UNA service string advice and delimiter bytes in the payload")
486            }
487            Self::InvalidText { .. } => {
488                Some("Input must be valid UTF-8 text for segment and element values")
489            }
490            Self::InvalidReleaseSequence { .. } => {
491                Some("Release character must escape one following byte; trailing '?' is invalid")
492            }
493            Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
494            Self::InvalidUna => Some(
495                "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
496            ),
497            Self::MissingRequiredElement { .. } => {
498                Some("Provide all mandatory elements for the segment per directory rules")
499            }
500            Self::MissingRequiredComponent { .. } => Some(
501                "Provide all mandatory components for the composite element per directory rules",
502            ),
503            Self::InvalidSegmentForMessage { .. } => {
504                Some("Remove unsupported segment or switch to the correct message type")
505            }
506            Self::InvalidElementCount { .. } => {
507                Some("Adjust the segment element count to the allowed min/max range")
508            }
509            Self::InvalidComponentCount { .. } => {
510                Some("Fix composite element arity to match the expected component count")
511            }
512            Self::InvalidCodeValue { .. } => {
513                Some("Use a value from the referenced code list for this element")
514            }
515            Self::MissingSegment { .. } => {
516                Some("Insert the required segment at the expected position")
517            }
518            Self::QualifierMismatch { .. } => {
519                Some("Set the segment qualifier to the expected value")
520            }
521            Self::ConditionalRequirementNotMet { .. } => {
522                Some("When the condition is met, include the conditionally required element")
523            }
524            Self::SegmentTooLong { limit, .. } => {
525                let _ = limit; // used in the error message; hint is generic
526                Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
527            }
528            Self::InvalidEventSequence { .. } => {
529                Some("Emit StartSegment before Element, and Element before ComponentElement")
530            }
531            Self::InvalidElementPosition => Some(
532                "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
533            ),
534            Self::IncompatibleReleaseScopes { .. } => Some(
535                "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
536            ),
537            Self::InvalidFieldValue { .. } => Some(
538                "Correct the field value to match the expected format or range for this element",
539            ),
540            Self::UnexpectedDataToken { .. } => Some(
541                "A data element appeared before any segment tag; check for partial writes or encoding corruption",
542            ),
543            Self::ValidationErrors { .. }
544            | Self::MessageCountMismatch { .. }
545            | Self::SegmentCountMismatch { .. }
546            | Self::UnexpectedMessageType { .. }
547            | Self::InterchangeTooLarge { .. }
548            | Self::UnrecognisedSyntaxIdentifier(_)
549            | Self::InvalidUtf8
550            | Self::Io(_) => None,
551        }
552    }
553}
554
555#[cfg(feature = "diagnostics")]
556#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
557impl miette::Diagnostic for EdifactError {
558    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
559        Some(Box::new(self.stable_code()))
560    }
561
562    fn severity(&self) -> Option<miette::Severity> {
563        match self {
564            Self::InvalidCodeValue { .. }
565            | Self::InvalidComponentCount { .. }
566            | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
567            _ => Some(miette::Severity::Error),
568        }
569    }
570
571    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
572        match self {
573            // Static text — no allocation needed.
574            Self::InvalidUna => Some(Box::new(
575                "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
576            )),
577            Self::InvalidUtf8 => Some(Box::new(
578                "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
579            )),
580            // Dynamic help text.
581            Self::UnexpectedEof { offset } => Some(Box::new(format!(
582                "Check that all segments are terminated with the segment terminator (usually '). \
583                 Reached end at offset {offset}",
584            ))),
585            Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
586                "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
587                 Check UNA configuration",
588            ))),
589            Self::InvalidText { offset } => Some(Box::new(format!(
590                "The byte sequence at offset {offset} contains invalid UTF-8. \
591                 Ensure input is valid UTF-8",
592            ))),
593            Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
594                "Release character at offset {offset} is dangling. \
595                 Ensure '?' is followed by an escaped byte",
596            ))),
597            Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
598                "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
599                 Check the UNZ message count",
600            ))),
601            Self::SegmentCountMismatch {
602                expected,
603                actual,
604                message_ref,
605            } => Some(Box::new(format!(
606                "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
607                 Check the UNT segment count",
608            ))),
609            Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
610                "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
611            ))),
612            Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
613                "Segment {tag} requires element at index {element_index}",
614            ))),
615            Self::MissingRequiredComponent {
616                tag,
617                element_index,
618                component_index,
619            } => Some(Box::new(format!(
620                "Segment {tag} element {element_index} requires component at index {component_index}",
621            ))),
622            Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
623            Self::InvalidSegmentForMessage {
624                tag, message_type, ..
625            } => Some(Box::new(format!(
626                "Segment {tag} should not appear in a {message_type} message. \
627                 Check the directory definition",
628            ))),
629            Self::InvalidElementCount {
630                tag,
631                min,
632                max,
633                actual,
634                ..
635            } => Some(Box::new(format!(
636                "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
637                 Check segment structure",
638            ))),
639            Self::InvalidComponentCount {
640                tag,
641                element_index,
642                expected,
643                actual,
644                ..
645            } => Some(Box::new(format!(
646                "In segment {tag}, element {element_index} should have {expected} components \
647                     but has {actual}. Check element structure",
648            ))),
649            Self::InvalidCodeValue {
650                tag,
651                element_index,
652                value,
653                code_list,
654                ..
655            } => Some(Box::new(format!(
656                "Value '{value}' in segment {tag} element {element_index} is not in the \
657                     {code_list} code list. Check the directory for valid codes",
658            ))),
659            Self::MissingSegment {
660                tag,
661                expected_position,
662            } => Some(Box::new(format!(
663                "Segment {tag} is required at position {expected_position} but is missing. \
664                 Add this segment to the message",
665            ))),
666            Self::QualifierMismatch {
667                tag,
668                actual,
669                expected,
670                ..
671            } => Some(Box::new(format!(
672                "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
673                 Check the segment's first component",
674            ))),
675            Self::ConditionalRequirementNotMet {
676                tag,
677                element_index,
678                condition,
679                ..
680            } => Some(Box::new(format!(
681                "In segment {tag}, element {element_index} is conditionally required when: \
682                     {condition}. Check if the condition is met",
683            ))),
684            Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
685                "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
686                 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
687                 or verify the input for a missing segment terminator",
688            ))),
689            Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
690                "No handler was registered for message type '{message_type}'. \
691                 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
692            ))),
693            Self::InterchangeTooLarge { count } => Some(Box::new(format!(
694                "Interchange contains {count} items which exceeds the u32::MAX limit. \
695                 This is an extremely unusual input; verify the message is not corrupted.",
696            ))),
697            Self::InvalidEventSequence { message } => Some(Box::new(format!(
698                "Event sequence violation: {message}. \
699                 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
700            ))),
701            Self::InvalidElementPosition => Some(Box::new(
702                "Element positions must be >= 1 (one-based). \
703                 Ensure no OwnedElementRef is constructed with position == 0",
704            )),
705            Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
706                "Release scope {current:?} and {incoming:?} are incompatible. \
707                 Only compose ProfileRulePack values that share the same release scope, \
708                 or where at most one carries a release scope",
709            ))),
710            Self::InvalidFieldValue {
711                tag,
712                element_index,
713                value,
714            } => Some(Box::new(format!(
715                "Segment {tag} element {element_index} has invalid value '{value}'. \
716                 Check the expected format or range for this field",
717            ))),
718            Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
719                "Data element at offset {offset} appeared before any segment tag. \
720                 Check for partial writes or encoding corruption",
721            ))),
722            Self::ValidationErrors { error_count, .. } => Some(Box::new(format!(
723                "Validation found {error_count} error(s). Inspect the ValidationReport for details",
724            ))),
725            Self::UnrecognisedSyntaxIdentifier(id) => Some(Box::new(format!(
726                "Syntax identifier '{id}' is not defined in ISO 9735-1. \
727                 Valid values are UNOA, UNOB, UNOC, UNOD, UNOE, UNOF (or KECA for KEC-A profile)",
728            ))),
729        }
730    }
731}
732
733// ── validation report ─────────────────────────────────────────────────────────
734
735pub use crate::report::ValidationReport;
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    #[test]
742    fn recovery_hint_exists_for_common_malformed_cases() {
743        let err = EdifactError::InvalidReleaseSequence { offset: 10 };
744        assert!(err.recovery_hint().is_some());
745
746        let err = EdifactError::InvalidCodeValue {
747            tag: "BGM".to_owned(),
748            element_index: 0,
749            value: "X".to_owned(),
750            code_list: "1001".to_owned(),
751            offset: 0,
752            suggestion: None,
753        };
754        assert!(err.recovery_hint().is_some());
755    }
756}