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