Skip to main content

edifact_rs/
error.rs

1use thiserror::Error;
2
3/// Wrapper around [`std::io::Error`] that implements [`PartialEq`] by comparing [`std::io::ErrorKind`].
4///
5/// This allows `EdifactError` to derive `PartialEq` without requiring `std::io::Error: PartialEq`.
6#[derive(Debug)]
7pub struct IoError(pub(crate) std::io::Error);
8
9impl IoError {
10    /// Returns a reference to the underlying [`std::io::Error`].
11    pub fn inner(&self) -> &std::io::Error {
12        &self.0
13    }
14}
15
16impl PartialEq for IoError {
17    /// Equality is determined by [`std::io::ErrorKind`] only.
18    ///
19    /// Two `IoError` values with the same kind but different OS-level error codes
20    /// (or different messages) will compare as equal.  This is a deliberate
21    /// limitation: `std::io::Error` is not `PartialEq`, so kind-based comparison
22    /// is the only practical option that lets `EdifactError` derive `PartialEq`.
23    fn eq(&self, other: &Self) -> bool {
24        self.0.kind() == other.0.kind()
25    }
26}
27
28impl std::fmt::Display for IoError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        self.0.fmt(f)
31    }
32}
33
34impl std::error::Error for IoError {
35    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36        self.0.source()
37    }
38}
39
40impl From<std::io::Error> for IoError {
41    fn from(e: std::io::Error) -> Self {
42        Self(e)
43    }
44}
45
46/// All errors produced by `edifact-rs`.
47///
48/// # Error Variants
49///
50/// All variants that include an offset carry byte position information from the input stream.
51/// This data enables precise error location reporting in diagnostics.
52#[derive(Debug, Error, PartialEq)]
53#[non_exhaustive]
54pub enum EdifactError {
55    /// Unexpected end of input while parsing.
56    ///
57    /// This typically occurs when a segment terminator or expected delimiter
58    /// is not found before the end of the input stream.
59    #[error("unexpected end of input at byte offset {offset}")]
60    UnexpectedEof {
61        /// Byte offset where the parser exhausted input.
62        offset: usize,
63    },
64
65    /// Invalid byte encountered in a delimiter context.
66    ///
67    /// Delimiters must be precisely ASCII characters from the UNA service string advice.
68    /// Any other byte is invalid in delimiter position.
69    #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
70    InvalidDelimiter {
71        /// Unexpected delimiter byte.
72        byte: u8,
73        /// Byte offset where the delimiter was observed.
74        offset: usize,
75    },
76
77    /// Invalid UTF-8 sequence in parsed text.
78    ///
79    /// While EDIFACT operates on bytes, segments and elements are expected to contain
80    /// valid UTF-8 text. Non-UTF-8 sequences are rejected at parse time.
81    #[error("invalid EDIFACT text at byte offset {offset}")]
82    InvalidText {
83        /// Byte offset where invalid UTF-8 text starts.
84        offset: usize,
85    },
86
87    /// Invalid release-character escape sequence in parsed text.
88    ///
89    /// The release character (`?` by default) must be followed by one escaped byte.
90    /// A trailing release character without a following byte is malformed.
91    #[error("invalid release sequence at byte offset {offset}: dangling release character")]
92    InvalidReleaseSequence {
93        /// Byte offset of the dangling release character.
94        offset: usize,
95    },
96
97    /// UNZ interchange message count does not match the number of UNH/UNT pairs found.
98    ///
99    /// The `UNZ` segment declares the number of messages in the interchange,
100    /// but the actual number of `UNH`/`UNT` pairs observed differs.
101    #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
102    MessageCountMismatch {
103        /// Message count declared in the UNZ segment.
104        expected: u32,
105        /// Actual number of UNH/UNT pairs observed.
106        actual: u32,
107    },
108
109    /// UNT segment count does not match the actual number of segments in the message.
110    ///
111    /// The `UNT` segment declares the number of segments in the message (including `UNH`/`UNT`),
112    /// but the actual count differs.
113    #[error(
114        "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
115    )]
116    SegmentCountMismatch {
117        /// Segment count declared in the UNT segment.
118        expected: u32,
119        /// Actual number of segments observed.
120        actual: u32,
121        /// Message reference from the UNH segment.
122        message_ref: String,
123    },
124
125    /// Invalid or malformed segment tag.
126    ///
127    /// Segment tags must be exactly 3 ASCII uppercase letters.
128    #[error("invalid segment tag {0:?}")]
129    InvalidSegmentTag(String),
130
131    /// Invalid UNA service string advice.
132    ///
133    /// If present, the UNA segment must be exactly 9 bytes: `"UNA"` followed by
134    /// 6 service characters.  The four active characters (element separator,
135    /// component separator, release character, and segment terminator) must be
136    /// mutually distinct and must not be ASCII whitespace.  The decimal mark and
137    /// repetition separator characters are not validated by this check.
138    #[error("invalid UNA service string advice")]
139    InvalidUna,
140
141    /// Missing required element in a segment.
142    ///
143    /// Certain segments require specific elements to be present. This error indicates
144    /// a mandatory element was not found.
145    #[error("missing required element {element_index} in segment {tag}")]
146    MissingRequiredElement {
147        /// Segment tag containing the missing element.
148        tag: String,
149        /// Zero-based required element index.
150        element_index: usize,
151    },
152
153    /// Missing required component in a composite element.
154    ///
155    /// The element is present, but the required component at the given index is absent or empty.
156    #[error(
157        "missing required component {component_index} in element {element_index} of segment {tag}"
158    )]
159    MissingRequiredComponent {
160        /// Segment tag containing the composite element.
161        tag: String,
162        /// Zero-based element index of the composite.
163        element_index: usize,
164        /// Zero-based component index that was absent.
165        component_index: usize,
166    },
167
168    /// Output serialization produced invalid UTF-8.
169    ///
170    /// This is an internal consistency error; the writer should never produce non-UTF-8 output.
171    /// If this occurs, it indicates a bug in the serialization logic.
172    #[error("serialized output contains invalid UTF-8")]
173    InvalidUtf8,
174
175    /// I/O error from reading or writing.
176    #[error(transparent)]
177    Io(#[from] IoError),
178
179    // ── validation variants (E010–E020) ────────────────────────────────────
180    /// Segment is not valid for the current message type.
181    ///
182    /// Structural validation found a segment that should not appear in this message.
183    #[error("segment {tag} is not valid for message type {message_type}")]
184    InvalidSegmentForMessage {
185        /// Segment tag that is not allowed for the message type.
186        tag: String,
187        /// Message type used for structural validation.
188        message_type: String,
189        /// Segment tag byte offset.
190        offset: usize,
191    },
192
193    /// Element count in segment exceeds or falls short of directory definition.
194    ///
195    /// Validation against directory metadata found an element count mismatch.
196    #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
197    InvalidElementCount {
198        /// Segment tag with wrong arity.
199        tag: String,
200        /// Minimum allowed element count.
201        min: usize,
202        /// Maximum allowed element count.
203        max: usize,
204        /// Actual element count found.
205        actual: usize,
206        /// Segment start byte offset.
207        offset: usize,
208    },
209
210    /// Component count in a composite element is invalid.
211    ///
212    /// A composite data element does not have the expected number of components.
213    #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
214    InvalidComponentCount {
215        /// Segment tag containing the composite.
216        tag: String,
217        /// Zero-based element index of the composite.
218        element_index: usize,
219        /// Expected component count.
220        expected: u8,
221        /// Actual component count found.
222        actual: u8,
223        /// Segment start byte offset.
224        offset: usize,
225    },
226
227    /// Code-list value is not valid.
228    ///
229    /// The value appears in a field that should contain a code from a specific code list,
230    /// but the value is not in that code list.
231    #[error(
232        "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
233    )]
234    InvalidCodeValue {
235        /// Segment tag containing the invalid value.
236        tag: String,
237        /// Zero-based element index containing the invalid code.
238        element_index: usize,
239        /// Invalid code value observed.
240        value: String,
241        /// Data element code list identifier.
242        code_list: String,
243        /// Segment start byte offset.
244        offset: usize,
245        /// Optional remediation suggestion from the code-list lookup function.
246        suggestion: Option<&'static str>,
247    },
248
249    /// A required segment is missing from the message.
250    ///
251    /// Structural validation found that a mandatory segment is absent.
252    #[error("required segment {tag} is missing from message (position {expected_position})")]
253    MissingSegment {
254        /// Missing segment tag.
255        tag: String,
256        /// Human-readable position hint.
257        expected_position: String,
258    },
259
260    /// Qualifier does not match expected value for segment.
261    ///
262    /// A qualified segment (e.g., NAD+MS) has a qualifier that does not match expected.
263    #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
264    QualifierMismatch {
265        /// Segment tag whose qualifier mismatched.
266        tag: String,
267        /// Actual qualifier found.
268        actual: String,
269        /// Expected qualifier value.
270        expected: String,
271        /// Segment start byte offset.
272        offset: usize,
273    },
274
275    /// Conditional requirement not met.
276    ///
277    /// A segment or element is conditionally required based on another element's value,
278    /// but the condition was not satisfied.
279    #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
280    ConditionalRequirementNotMet {
281        /// Segment tag that violated a conditional rule.
282        tag: String,
283        /// Zero-based element index governed by the condition.
284        element_index: usize,
285        /// Condition text describing the rule.
286        condition: String,
287        /// Segment start byte offset.
288        offset: usize,
289    },
290
291    /// Validation failed and the full [`ValidationReport`] is preserved.
292    ///
293    /// Returned by validation helpers when errors are found.  Provides programmatic
294    /// access to all issues, warnings, and infos.
295    ///
296    /// # Example
297    ///
298    /// ```rust,ignore
299    /// match my_fn() {
300    ///     Err(EdifactError::ValidationErrors { report, .. }) => {
301    ///         for issue in report.errors() {
302    ///             eprintln!("{}", issue);
303    ///         }
304    ///     }
305    ///     other => { /* ... */ }
306    /// }
307    /// ```
308    #[error("validation failed with {error_count} error(s)")]
309    ValidationErrors {
310        /// Number of error-severity issues in the report.
311        error_count: usize,
312        /// Full report with all errors, warnings, and infos.
313        report: Box<ValidationReport>,
314    },
315
316    /// Segment exceeded the configured maximum byte length.
317    ///
318    /// Returned by reader-based parsers when an unterminated segment accumulates more
319    /// bytes than the configured `max_segment_bytes` limit in [`ReaderConfig`].  This
320    /// prevents resource exhaustion on adversarially crafted or truncated input that
321    /// never emits a segment terminator.
322    ///
323    /// [`ReaderConfig`]: crate::ReaderConfig
324    #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
325    SegmentTooLong {
326        /// Byte offset where the overlong segment started.
327        offset: usize,
328        /// Configured maximum segment byte length.
329        limit: usize,
330    },
331
332    /// No handler was registered in [`crate::MessageDispatch`] for this message type.
333    ///
334    /// Returned by [`crate::MessageDispatch::dispatch`] when the message-type
335    /// extracted from the `UNH` segment does not match any registered handler
336    /// and no fallback was configured.
337    #[error("no handler registered for message type {message_type}")]
338    UnexpectedMessageType {
339        /// The unhandled message type string from the `UNH` segment.
340        message_type: String,
341    },
342
343    /// An interchange or message contains more segments or messages than can be
344    /// represented in a `u32` counter (> 4 294 967 295).
345    ///
346    /// This is effectively unreachable in practice — no real-world EDIFACT
347    /// interchange has billions of segments — but the parser returns this error
348    /// rather than silently saturating or wrapping the counter.
349    #[error("interchange too large: count {count} exceeds u32::MAX")]
350    InterchangeTooLarge {
351        /// The count that could not be represented as `u32`.
352        count: u64,
353    },
354
355    /// An [`crate::EventEmitter`] received events in an invalid sequence.
356    ///
357    /// This indicates a programming error in the caller's serialization code:
358    /// for example, emitting an [`crate::EdifactEvent::Element`] without a prior
359    /// [`crate::EdifactEvent::StartSegment`], or emitting
360    /// [`crate::EdifactEvent::ComponentElement`] without a preceding
361    /// [`crate::EdifactEvent::Element`].
362    #[error("invalid event sequence: {message}")]
363    InvalidEventSequence {
364        /// Description of the protocol violation.
365        message: &'static str,
366    },
367
368    /// An [`crate::OwnedElementRef`] has `position = 0`, which is never valid.
369    ///
370    /// Element positions are one-based: position 1 refers to the first element
371    /// slot.  Position 0 is reserved and invalid.  Use [`crate::OwnedElementRef::try_new`]
372    /// to get a `Result` instead of a panic.
373    #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
374    InvalidElementPosition,
375
376    /// Two [`crate::ProfileRulePack`] values with incompatible release scopes were composed.
377    ///
378    /// When composing packs via [`crate::ProfileRulePack::extend_from`] or
379    /// [`crate::ProfileRulePack::merge_with_override`], both packs must either
380    /// share the same release scope or at most one may carry a scope.
381    #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
382    IncompatibleReleaseScopes {
383        /// Release scope of the pack being composed into.
384        current: String,
385        /// Release scope of the pack being composed in.
386        incoming: String,
387    },
388
389    /// A field value failed semantic validation (e.g. wrong format, out-of-range).
390    ///
391    /// Distinct from [`InvalidCodeValue`][Self::InvalidCodeValue] which is for
392    /// code-list membership checks.  Use this variant when a free-text or numeric
393    /// field contains a value that is structurally invalid for its purpose.
394    #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
395    InvalidFieldValue {
396        /// Segment tag that contains the invalid field.
397        tag: String,
398        /// Zero-based element index of the invalid field.
399        element_index: usize,
400        /// The invalid value that was observed.
401        value: String,
402    },
403
404    /// A data or component element token appeared before the first segment tag.
405    ///
406    /// EDIFACT syntax requires that every data element follows a segment tag.
407    /// A data element token encountered before any tag (e.g. after a stray
408    /// separator at the start of the stream) is a protocol violation.
409    ///
410    /// Unlike stray segment terminators (which are tolerated as blank lines),
411    /// stray data tokens indicate encoding corruption or a partial write.
412    #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
413    UnexpectedDataToken {
414        /// Byte offset of the stray token.
415        offset: usize,
416    },
417
418    /// The input contains EDIFACT functional group segments (`UNG`/`UNE`).
419    ///
420    /// Functional groups are defined in ISO 9735 but are rarely used in practice
421    /// and are not supported by this library.  Strip `UNG`/`UNE` wrappers before
422    /// calling `validate_envelope`, or process the interchange as raw segments.
423    #[error(
424        "functional group segments (UNG/UNE) at byte offset {offset} are not supported; \
425         strip them before calling validate_envelope"
426    )]
427    FunctionalGroupNotSupported {
428        /// Byte offset of the first `UNG` or `UNE` segment found.
429        offset: usize,
430    },
431}
432
433impl From<std::io::Error> for EdifactError {
434    fn from(e: std::io::Error) -> Self {
435        Self::Io(IoError(e))
436    }
437}
438
439impl EdifactError {
440    /// Stable diagnostic code for this error variant.
441    #[must_use]
442    pub const fn stable_code(&self) -> &'static str {
443        match self {
444            Self::UnexpectedEof { .. } => "E001",
445            Self::InvalidDelimiter { .. } => "E002",
446            Self::InvalidText { .. } => "E003",
447            Self::MessageCountMismatch { .. } => "E004",
448            Self::SegmentCountMismatch { .. } => "E005",
449            Self::InvalidSegmentTag(_) => "E006",
450            Self::InvalidUna => "E007",
451            Self::MissingRequiredElement { .. } => "E008",
452            Self::InvalidUtf8 => "E009",
453            Self::Io(_) => "E010",
454            Self::InvalidSegmentForMessage { .. } => "E011",
455            Self::InvalidElementCount { .. } => "E012",
456            Self::InvalidComponentCount { .. } => "E013",
457            Self::InvalidCodeValue { .. } => "E014",
458            Self::MissingSegment { .. } => "E015",
459            Self::QualifierMismatch { .. } => "E016",
460            Self::ConditionalRequirementNotMet { .. } => "E017",
461            // E018 is permanently retired (was ValidationFailed, removed in 0.8.0)
462            Self::InvalidReleaseSequence { .. } => "E019",
463            Self::SegmentTooLong { .. } => "E020",
464            Self::MissingRequiredComponent { .. } => "E021",
465            Self::UnexpectedMessageType { .. } => "E022",
466            Self::InterchangeTooLarge { .. } => "E023",
467            Self::InvalidEventSequence { .. } => "E024",
468            Self::InvalidElementPosition => "E025",
469            Self::IncompatibleReleaseScopes { .. } => "E026",
470            Self::InvalidFieldValue { .. } => "E027",
471            Self::UnexpectedDataToken { .. } => "E028",
472            Self::FunctionalGroupNotSupported { .. } => "E029",
473            Self::ValidationErrors { .. } => "E030",
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::FunctionalGroupNotSupported { .. } => Some(
544                "Strip UNG/UNE segments before calling validate_envelope, or process the interchange as raw segments",
545            ),
546            Self::ValidationErrors { .. }
547            | Self::MessageCountMismatch { .. }
548            | Self::SegmentCountMismatch { .. }
549            | Self::UnexpectedMessageType { .. }
550            | Self::InterchangeTooLarge { .. }
551            | Self::InvalidUtf8
552            | Self::Io(_) => None,
553        }
554    }
555}
556
557#[cfg(feature = "diagnostics")]
558#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
559impl miette::Diagnostic for EdifactError {
560    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
561        Some(Box::new(self.stable_code()))
562    }
563
564    fn severity(&self) -> Option<miette::Severity> {
565        match self {
566            Self::InvalidCodeValue { .. }
567            | Self::InvalidComponentCount { .. }
568            | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
569            _ => Some(miette::Severity::Error),
570        }
571    }
572
573    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
574        match self {
575            // Static text — no allocation needed.
576            Self::InvalidUna => Some(Box::new(
577                "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
578            )),
579            Self::InvalidUtf8 => Some(Box::new(
580                "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
581            )),
582            // Dynamic help text.
583            Self::UnexpectedEof { offset } => Some(Box::new(format!(
584                "Check that all segments are terminated with the segment terminator (usually '). \
585                 Reached end at offset {offset}",
586            ))),
587            Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
588                "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
589                 Check UNA configuration",
590            ))),
591            Self::InvalidText { offset } => Some(Box::new(format!(
592                "The byte sequence at offset {offset} contains invalid UTF-8. \
593                 Ensure input is valid UTF-8",
594            ))),
595            Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
596                "Release character at offset {offset} is dangling. \
597                 Ensure '?' is followed by an escaped byte",
598            ))),
599            Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
600                "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
601                 Check the UNZ message count",
602            ))),
603            Self::SegmentCountMismatch {
604                expected,
605                actual,
606                message_ref,
607            } => Some(Box::new(format!(
608                "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
609                 Check the UNT segment count",
610            ))),
611            Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
612                "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
613            ))),
614            Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
615                "Segment {tag} requires element at index {element_index}",
616            ))),
617            Self::MissingRequiredComponent {
618                tag,
619                element_index,
620                component_index,
621            } => Some(Box::new(format!(
622                "Segment {tag} element {element_index} requires component at index {component_index}",
623            ))),
624            Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
625            Self::InvalidSegmentForMessage {
626                tag, message_type, ..
627            } => Some(Box::new(format!(
628                "Segment {tag} should not appear in a {message_type} message. \
629                 Check the directory definition",
630            ))),
631            Self::InvalidElementCount {
632                tag,
633                min,
634                max,
635                actual,
636                ..
637            } => Some(Box::new(format!(
638                "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
639                 Check segment structure",
640            ))),
641            Self::InvalidComponentCount {
642                tag,
643                element_index,
644                expected,
645                actual,
646                ..
647            } => Some(Box::new(format!(
648                "In segment {tag}, element {element_index} should have {expected} components \
649                     but has {actual}. Check element structure",
650            ))),
651            Self::InvalidCodeValue {
652                tag,
653                element_index,
654                value,
655                code_list,
656                ..
657            } => Some(Box::new(format!(
658                "Value '{value}' in segment {tag} element {element_index} is not in the \
659                     {code_list} code list. Check the directory for valid codes",
660            ))),
661            Self::MissingSegment {
662                tag,
663                expected_position,
664            } => Some(Box::new(format!(
665                "Segment {tag} is required at position {expected_position} but is missing. \
666                 Add this segment to the message",
667            ))),
668            Self::QualifierMismatch {
669                tag,
670                actual,
671                expected,
672                ..
673            } => Some(Box::new(format!(
674                "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
675                 Check the segment's first component",
676            ))),
677            Self::ConditionalRequirementNotMet {
678                tag,
679                element_index,
680                condition,
681                ..
682            } => Some(Box::new(format!(
683                "In segment {tag}, element {element_index} is conditionally required when: \
684                     {condition}. Check if the condition is met",
685            ))),
686            Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
687                "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
688                 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
689                 or verify the input for a missing segment terminator",
690            ))),
691            Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
692                "No handler was registered for message type '{message_type}'. \
693                 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
694            ))),
695            Self::InterchangeTooLarge { count } => Some(Box::new(format!(
696                "Interchange contains {count} items which exceeds the u32::MAX limit. \
697                 This is an extremely unusual input; verify the message is not corrupted.",
698            ))),
699            Self::InvalidEventSequence { message } => Some(Box::new(format!(
700                "Event sequence violation: {message}. \
701                 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
702            ))),
703            Self::InvalidElementPosition => Some(Box::new(
704                "Element positions must be >= 1 (one-based). \
705                 Ensure no OwnedElementRef is constructed with position == 0",
706            )),
707            Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
708                "Release scope {current:?} and {incoming:?} are incompatible. \
709                 Only compose ProfileRulePack values that share the same release scope, \
710                 or where at most one carries a release scope",
711            ))),
712            Self::InvalidFieldValue {
713                tag,
714                element_index,
715                value,
716            } => Some(Box::new(format!(
717                "Segment {tag} element {element_index} has invalid value '{value}'. \
718                 Check the expected format or range for this field",
719            ))),
720            Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
721                "Data element at offset {offset} appeared before any segment tag. \
722                 Check for partial writes or encoding corruption",
723            ))),
724            Self::FunctionalGroupNotSupported { offset } => Some(Box::new(format!(
725                "Functional group segment (UNG/UNE) found at offset {offset}. \
726                 Strip UNG/UNE wrappers before calling validate_envelope",
727            ))),
728            Self::ValidationErrors { error_count, .. } => Some(Box::new(format!(
729                "Validation found {error_count} error(s). Inspect the ValidationReport for details",
730            ))),
731        }
732    }
733}
734
735// ── validation report ─────────────────────────────────────────────────────────
736
737pub use crate::report::ValidationReport;
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    #[test]
744    fn recovery_hint_exists_for_common_malformed_cases() {
745        let err = EdifactError::InvalidReleaseSequence { offset: 10 };
746        assert!(err.recovery_hint().is_some());
747
748        let err = EdifactError::InvalidCodeValue {
749            tag: "BGM".to_owned(),
750            element_index: 0,
751            value: "X".to_owned(),
752            code_list: "1001".to_owned(),
753            offset: 0,
754            suggestion: None,
755        };
756        assert!(err.recovery_hint().is_some());
757    }
758}