Skip to main content

edifact_rs/
error.rs

1use crate::model::Span;
2use thiserror::Error;
3
4/// Wrapper around [`std::io::Error`] that implements [`PartialEq`] by comparing [`std::io::ErrorKind`].
5///
6/// This allows `EdifactError` to derive `PartialEq` without requiring `std::io::Error: PartialEq`.
7#[derive(Debug)]
8pub struct IoError(pub(crate) std::io::Error);
9
10impl IoError {
11    /// Returns a reference to the underlying [`std::io::Error`].
12    pub fn inner(&self) -> &std::io::Error {
13        &self.0
14    }
15}
16
17impl PartialEq for IoError {
18    /// Equality is determined by [`std::io::ErrorKind`] only.
19    ///
20    /// Two `IoError` values with the same kind but different OS-level error codes
21    /// (or different messages) will compare as equal.  This is a deliberate
22    /// limitation: `std::io::Error` is not `PartialEq`, so kind-based comparison
23    /// is the only practical option that lets `EdifactError` derive `PartialEq`.
24    fn eq(&self, other: &Self) -> bool {
25        self.0.kind() == other.0.kind()
26    }
27}
28
29impl std::fmt::Display for IoError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        self.0.fmt(f)
32    }
33}
34
35impl std::error::Error for IoError {
36    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
37        self.0.source()
38    }
39}
40
41impl From<std::io::Error> for IoError {
42    fn from(e: std::io::Error) -> Self {
43        Self(e)
44    }
45}
46
47/// All errors produced by `edifact-rs`.
48///
49/// # Positional data
50///
51/// Two kinds of position information appear in this enum, and the distinction is
52/// deliberate:
53///
54/// * **`offset: usize`** — a single byte position in the input stream.  Used by
55///   the lexical variants (`UnexpectedEof`, `InvalidDelimiter`, `InvalidText`,
56///   `InvalidReleaseSequence`, `SegmentTooLong`, `UnexpectedDataToken`), where
57///   the fault is a *point* in the byte stream and no meaningful end position
58///   exists.
59/// * **`span: Span`** — a half-open byte range.  Used by every variant produced
60///   while validating an already-parsed [`Segment`][crate::Segment], where the
61///   exact source range is known.  A [`ValidationIssue`][crate::ValidationIssue]
62///   built from such an error carries the full range, so `miette` and LSP
63///   tooling can underline the offending segment rather than place a
64///   zero-width caret.
65///
66/// Use `span.start` when only the start position is needed.
67#[derive(Debug, Error, PartialEq)]
68#[non_exhaustive]
69pub enum EdifactError {
70    /// Unexpected end of input while parsing.
71    ///
72    /// This typically occurs when a segment terminator or expected delimiter
73    /// is not found before the end of the input stream.
74    #[error("unexpected end of input at byte offset {offset}")]
75    UnexpectedEof {
76        /// Byte offset where the parser exhausted input.
77        offset: usize,
78    },
79
80    /// Invalid byte encountered in a delimiter context.
81    ///
82    /// Delimiters must be precisely ASCII characters from the UNA service string advice.
83    /// Any other byte is invalid in delimiter position.
84    #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
85    InvalidDelimiter {
86        /// Unexpected delimiter byte.
87        byte: u8,
88        /// Byte offset where the delimiter was observed.
89        offset: usize,
90    },
91
92    /// Invalid UTF-8 sequence in parsed text.
93    ///
94    /// While EDIFACT operates on bytes, segments and elements are expected to contain
95    /// valid UTF-8 text. Non-UTF-8 sequences are rejected at parse time.
96    #[error("invalid EDIFACT text at byte offset {offset}")]
97    InvalidText {
98        /// Byte offset where invalid UTF-8 text starts.
99        offset: usize,
100    },
101
102    /// Invalid release-character escape sequence in parsed text.
103    ///
104    /// The release character (`?` by default) must be followed by one escaped byte.
105    /// A trailing release character without a following byte is malformed.
106    #[error("invalid release sequence at byte offset {offset}: dangling release character")]
107    InvalidReleaseSequence {
108        /// Byte offset of the dangling release character.
109        offset: usize,
110    },
111
112    /// UNZ interchange message count does not match the number of UNH/UNT pairs found.
113    ///
114    /// The `UNZ` segment declares the number of messages in the interchange,
115    /// but the actual number of `UNH`/`UNT` pairs observed differs.
116    #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
117    MessageCountMismatch {
118        /// Message count declared in the UNZ segment.
119        expected: u32,
120        /// Actual number of UNH/UNT pairs observed.
121        actual: u32,
122    },
123
124    /// UNT segment count does not match the actual number of segments in the message.
125    ///
126    /// The `UNT` segment declares the number of segments in the message (including `UNH`/`UNT`),
127    /// but the actual count differs.
128    #[error(
129        "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
130    )]
131    SegmentCountMismatch {
132        /// Segment count declared in the UNT segment.
133        expected: u32,
134        /// Actual number of segments observed.
135        actual: u32,
136        /// Message reference from the UNH segment.
137        message_ref: String,
138    },
139
140    /// Invalid or malformed segment tag.
141    ///
142    /// Segment tags must be exactly 3 ASCII uppercase letters.
143    #[error("invalid segment tag {0:?}")]
144    InvalidSegmentTag(String),
145
146    /// Invalid UNA service string advice.
147    ///
148    /// If present, the UNA segment must be exactly 9 bytes: `"UNA"` followed by
149    /// 6 service characters.  The five active characters (`element_sep`,
150    /// `component_sep`, `decimal_mark`, `release_char`, and `segment_term`) must
151    /// all be mutually distinct and printable, non-alphanumeric ASCII.  The
152    /// **repetition separator** (UNA byte 7) is validated on the same terms
153    /// unless it is a space, the conventional "not used" sentinel.
154    #[error("invalid UNA service string advice")]
155    InvalidUna,
156
157    /// Missing required element in a segment.
158    ///
159    /// Certain segments require specific elements to be present. This error indicates
160    /// a mandatory element was not found.
161    #[error("missing required element {element_index} in segment {tag}")]
162    MissingRequiredElement {
163        /// Segment tag containing the missing element.
164        tag: String,
165        /// Zero-based required element index.
166        element_index: usize,
167    },
168
169    /// Missing required component in a composite element.
170    ///
171    /// The element is present, but the required component at the given index is absent or empty.
172    #[error(
173        "missing required component {component_index} in element {element_index} of segment {tag}"
174    )]
175    MissingRequiredComponent {
176        /// Segment tag containing the composite element.
177        tag: String,
178        /// Zero-based element index of the composite.
179        element_index: usize,
180        /// Zero-based component index that was absent.
181        component_index: usize,
182    },
183
184    /// Output serialization produced invalid UTF-8.
185    ///
186    /// This is an internal consistency error; the writer should never produce non-UTF-8 output.
187    /// If this occurs, it indicates a bug in the serialization logic.
188    #[error("serialized output contains invalid UTF-8")]
189    InvalidUtf8,
190
191    /// I/O error from reading or writing.
192    #[error(transparent)]
193    Io(#[from] IoError),
194
195    // ── validation variants (E010–E020) ────────────────────────────────────
196    /// Segment is not valid for the current message type.
197    ///
198    /// Structural validation found a segment that should not appear in this message.
199    #[error("segment {tag} is not valid for message type {message_type}")]
200    InvalidSegmentForMessage {
201        /// Segment tag that is not allowed for the message type.
202        tag: String,
203        /// Message type used for structural validation.
204        message_type: String,
205        /// Byte range of the offending segment tag.
206        span: Span,
207    },
208
209    /// Element count in segment exceeds or falls short of directory definition.
210    ///
211    /// Validation against directory metadata found an element count mismatch.
212    #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
213    InvalidElementCount {
214        /// Segment tag with wrong arity.
215        tag: String,
216        /// Minimum allowed element count.
217        min: usize,
218        /// Maximum allowed element count.
219        max: usize,
220        /// Actual element count found.
221        actual: usize,
222        /// Byte range of the offending segment.
223        span: Span,
224    },
225
226    /// Component count in a composite element is invalid.
227    ///
228    /// A composite data element does not have the expected number of components.
229    #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
230    InvalidComponentCount {
231        /// Segment tag containing the composite.
232        tag: String,
233        /// Zero-based element index of the composite.
234        element_index: usize,
235        /// Expected component count.
236        expected: u8,
237        /// Actual component count found.
238        actual: u8,
239        /// Byte range of the offending composite element.
240        span: Span,
241    },
242
243    /// Code-list value is not valid.
244    ///
245    /// The value appears in a field that should contain a code from a specific code list,
246    /// but the value is not in that code list.
247    #[error(
248        "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
249    )]
250    InvalidCodeValue {
251        /// Segment tag containing the invalid value.
252        tag: String,
253        /// Zero-based element index containing the invalid code.
254        element_index: usize,
255        /// Invalid code value observed.
256        value: String,
257        /// Data element code list identifier.
258        code_list: String,
259        /// Byte range of the offending value.
260        span: Span,
261        /// Optional remediation suggestion from the code-list lookup function.
262        suggestion: Option<&'static str>,
263    },
264
265    /// A required segment is missing from the message.
266    ///
267    /// Structural validation found that a mandatory segment is absent.
268    #[error("required segment {tag} is missing from message (position {expected_position})")]
269    MissingSegment {
270        /// Missing segment tag.
271        tag: String,
272        /// Human-readable position hint.
273        expected_position: String,
274    },
275
276    /// Qualifier does not match expected value for segment.
277    ///
278    /// A qualified segment (e.g., NAD+MS) has a qualifier that does not match expected.
279    #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
280    QualifierMismatch {
281        /// Segment tag whose qualifier mismatched.
282        tag: String,
283        /// Actual qualifier found.
284        actual: String,
285        /// Expected qualifier value.
286        expected: String,
287        /// Byte range of the offending segment.
288        span: Span,
289    },
290
291    /// Conditional requirement not met.
292    ///
293    /// A segment or element is conditionally required based on another element's value,
294    /// but the condition was not satisfied.
295    #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
296    ConditionalRequirementNotMet {
297        /// Segment tag that violated a conditional rule.
298        tag: String,
299        /// Zero-based element index governed by the condition.
300        element_index: usize,
301        /// Condition text describing the rule.
302        condition: String,
303        /// Byte range of the offending segment.
304        span: Span,
305    },
306
307    /// Validation failed and the full [`ValidationReport`] is preserved.
308    ///
309    /// Returned by validation helpers when errors are found.  Provides programmatic
310    /// access to all issues, warnings, and infos.
311    ///
312    /// # Example
313    ///
314    /// ```rust,ignore
315    /// match my_fn() {
316    ///     Err(EdifactError::ValidationErrors { report, .. }) => {
317    ///         for issue in report.errors() {
318    ///             eprintln!("{}", issue);
319    ///         }
320    ///     }
321    ///     other => { /* ... */ }
322    /// }
323    /// ```
324    #[error("validation failed with {error_count} error(s)")]
325    ValidationErrors {
326        /// Number of error-severity issues in the report.
327        error_count: usize,
328        /// Full report with all errors, warnings, and infos.
329        report: Box<ValidationReport>,
330    },
331
332    /// Segment exceeded the configured maximum byte length.
333    ///
334    /// Returned by reader-based parsers when an unterminated segment accumulates more
335    /// bytes than the configured `max_segment_bytes` limit in [`ReaderConfig`].  This
336    /// prevents resource exhaustion on adversarially crafted or truncated input that
337    /// never emits a segment terminator.
338    ///
339    /// [`ReaderConfig`]: crate::ReaderConfig
340    #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
341    SegmentTooLong {
342        /// Byte offset where the overlong segment started.
343        offset: usize,
344        /// Configured maximum segment byte length.
345        limit: usize,
346    },
347
348    /// No handler was registered in [`crate::MessageDispatch`] for this message type.
349    ///
350    /// Returned by [`crate::MessageDispatch::dispatch`] when the message-type
351    /// extracted from the `UNH` segment does not match any registered handler
352    /// and no fallback was configured.
353    #[error("no handler registered for message type {message_type}")]
354    UnexpectedMessageType {
355        /// The unhandled message type string from the `UNH` segment.
356        message_type: String,
357    },
358
359    /// An interchange or message contains more segments or messages than can be
360    /// represented in a `u32` counter (> 4 294 967 295).
361    ///
362    /// This is effectively unreachable in practice — no real-world EDIFACT
363    /// interchange has billions of segments — but the parser returns this error
364    /// rather than silently saturating or wrapping the counter.
365    #[error("interchange too large: count {count} exceeds u32::MAX")]
366    InterchangeTooLarge {
367        /// The count that could not be represented as `u32`.
368        count: u64,
369    },
370
371    /// An [`crate::EventEmitter`] received events in an invalid sequence.
372    ///
373    /// This indicates a programming error in the caller's serialization code:
374    /// for example, emitting an [`crate::EdifactEvent::Element`] without a prior
375    /// [`crate::EdifactEvent::StartSegment`], or emitting
376    /// [`crate::EdifactEvent::ComponentElement`] without a preceding
377    /// [`crate::EdifactEvent::Element`].
378    #[error("invalid event sequence: {message}")]
379    InvalidEventSequence {
380        /// Description of the protocol violation.
381        message: &'static str,
382    },
383
384    /// An [`crate::OwnedElementRef`] has `position = 0`, which is never valid.
385    ///
386    /// Element positions are one-based: position 1 refers to the first element
387    /// slot.  Position 0 is reserved and invalid.  Use [`crate::OwnedElementRef::try_new`]
388    /// to get a `Result` instead of a panic.
389    #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
390    InvalidElementPosition,
391
392    /// Two [`crate::ProfileRulePack`] values with incompatible release scopes were composed.
393    ///
394    /// When composing packs via [`crate::ProfileRulePack::extend_from`] or
395    /// [`crate::ProfileRulePack::merge_with_override`], both packs must either
396    /// share the same release scope or at most one may carry a scope.
397    #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
398    IncompatibleReleaseScopes {
399        /// Release scope of the pack being composed into.
400        current: String,
401        /// Release scope of the pack being composed in.
402        incoming: String,
403    },
404
405    /// A field value failed semantic validation (e.g. wrong format, out-of-range).
406    ///
407    /// Distinct from [`InvalidCodeValue`][Self::InvalidCodeValue] which is for
408    /// code-list membership checks.  Use this variant when a free-text or numeric
409    /// field contains a value that is structurally invalid for its purpose.
410    #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
411    InvalidFieldValue {
412        /// Segment tag that contains the invalid field.
413        tag: String,
414        /// Zero-based element index of the invalid field.
415        element_index: usize,
416        /// The invalid value that was observed.
417        value: String,
418    },
419
420    /// A data or component element token appeared before the first segment tag.
421    ///
422    /// EDIFACT syntax requires that every data element follows a segment tag.
423    /// A data element token encountered before any tag (e.g. after a stray
424    /// separator at the start of the stream) is a protocol violation.
425    ///
426    /// Unlike stray segment terminators (which are tolerated as blank lines),
427    /// stray data tokens indicate encoding corruption or a partial write.
428    #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
429    UnexpectedDataToken {
430        /// Byte offset of the stray token.
431        offset: usize,
432    },
433
434    /// The interchange syntax identifier (UNB DE 0001) is not a recognised ISO 9735-1 value.
435    ///
436    /// Valid syntax identifiers are: `UNOA`, `UNOB`, `UNOC`, `UNOD`, `UNOE`, `UNOF`, and
437    /// `KECA` (Korean EDI Centre A).  Any other value indicates a non-standard generator
438    /// or a corrupted UNB header.
439    #[error(
440        "unrecognised syntax identifier '{0}': expected UNOA/UNOB/UNOC/UNOD/UNOE/UNOF (or KECA)"
441    )]
442    UnrecognisedSyntaxIdentifier(String),
443
444    /// A control reference was reused within the scope that requires it to be unique.
445    ///
446    /// ISO 9735-1 requires the message reference number (`UNH` DE 0062) to be
447    /// unique within an interchange, and the group reference number (`UNG`
448    /// DE 0048) to be unique within an interchange.  Duplicates make a message
449    /// unaddressable: a receiver keying on the reference silently processes one
450    /// occurrence and drops the rest.
451    #[error("duplicate {tag} reference '{reference}' at bytes {span}")]
452    DuplicateReference {
453        /// Segment tag that carries the duplicated reference (`UNH` or `UNG`).
454        tag: String,
455        /// The reference value that appeared more than once.
456        reference: String,
457        /// Byte range of the duplicate occurrence.
458        span: Span,
459    },
460
461    /// A UN/EDIFACT data element code was not found in the segment definition.
462    ///
463    /// Produced by the code-addressed accessors
464    /// ([`Segment::value_by_code`][crate::Segment::value_by_code] and friends)
465    /// when the requested data element identifier does not appear anywhere in
466    /// the supplied [`SegmentLayout`][crate::SegmentLayout].  This is the error
467    /// that turns a mistyped or stale DE reference into a loud failure instead
468    /// of a silent off-by-one read of the wrong element.
469    #[error("segment {tag} has no data element {data_element} in its definition")]
470    UnknownDataElement {
471        /// Segment tag whose definition was searched.
472        tag: String,
473        /// The data element identifier that was not found.
474        data_element: String,
475    },
476
477    /// A UN/EDIFACT data element code appears more than once in a segment definition.
478    ///
479    /// Code-addressed access requires an unambiguous target.  When a directory
480    /// genuinely repeats a code (e.g. the same DE used at two positions), address
481    /// it positionally with [`Segment::element_str`][crate::Segment::element_str]
482    /// or split the definition.
483    #[error("segment {tag} defines data element {data_element} at more than one position")]
484    AmbiguousDataElement {
485        /// Segment tag whose definition was searched.
486        tag: String,
487        /// The data element identifier that resolved to multiple positions.
488        data_element: String,
489    },
490
491    /// A configured [`ReaderConfig`][crate::ReaderConfig] resource limit was exceeded.
492    ///
493    /// Raised by the parsing iterators when the input carries more segments,
494    /// messages, or bytes than the caller allowed.  The limit is reported rather
495    /// than silently applied: a budget that ends the iterator without an error is
496    /// indistinguishable from a clean end of input, so the caller would accept a
497    /// **truncated** interchange as complete.
498    ///
499    /// [`SegmentTooLong`][Self::SegmentTooLong] covers the per-segment size
500    /// guard; this variant covers the whole-input budgets.
501    #[error("input exceeded the configured {limit} limit of {max}")]
502    LimitExceeded {
503        /// Name of the limit that tripped: `"max_segments"`, `"max_messages"`,
504        /// or `"max_input_bytes"`.
505        limit: &'static str,
506        /// The configured ceiling.
507        max: u64,
508    },
509
510    /// A repeating data element was written under a service string advice that
511    /// declares no repetition separator.
512    ///
513    /// ISO 9735-4 §3.1 repetitions can only be expressed when `UNA` position 7
514    /// carries a real separator.  With the space "not used" sentinel there is no
515    /// byte to write between occurrences, and joining them anyway would emit
516    /// output that reads back as a single occurrence — silent data corruption.
517    /// Construct the writer with [`Writer::with_una`][crate::Writer::with_una]
518    /// and a service string advice whose `repetition_sep` is set.
519    #[error(
520        "cannot write a repeating data element: the active service string advice declares no repetition separator"
521    )]
522    RepetitionSeparatorNotDeclared,
523
524    /// A non-finite float was handed to a numeric serializer.
525    ///
526    /// EDIFACT numeric data elements are decimal digit strings with an optional
527    /// sign and decimal mark (ISO 9735-1 §7).  There is no representation for
528    /// `NaN` or infinity, and `Display` would emit `NaN` / `inf` — text that no
529    /// receiver can parse and that the crate's own reader would reject.
530    #[error("non-finite number {value} has no EDIFACT representation")]
531    NonFiniteNumber {
532        /// The offending value, formatted for the message.
533        value: String,
534    },
535
536    /// A character cannot be represented in the interchange's declared repertoire.
537    ///
538    /// The `UNB` S001 DE 0001 syntax identifier names the character repertoire the
539    /// payload is written in (`UNOA`, `UNOC`, …).  Writing a character outside it
540    /// produces bytes the receiver decodes as something else — or as nothing at
541    /// all — so the writer refuses instead.
542    ///
543    /// Also raised by [`Charset::encode`][crate::Charset::encode].
544    #[error(
545        "character {character:?} at offset {offset} is not in the {charset} character repertoire"
546    )]
547    CharacterNotInRepertoire {
548        /// The syntax identifier of the repertoire that rejected the character.
549        charset: &'static str,
550        /// The offending character.
551        character: char,
552        /// Byte offset of the character within the value it appeared in.
553        offset: usize,
554    },
555
556    /// A `UNB` was written declaring a repertoire other than the writer's own.
557    ///
558    /// A header that names `UNOA` while the body goes out as ISO 8859-1 is
559    /// unreadable at the far end in exactly the way that is hardest to diagnose,
560    /// so [`Writer::begin_interchange`][crate::Writer::begin_interchange] refuses
561    /// the combination rather than emitting it.
562    #[error("UNB declares repertoire {declared}, but the writer encodes {writer}")]
563    CharacterRepertoireMismatch {
564        /// Syntax identifier passed to `begin_interchange`.
565        declared: String,
566        /// Repertoire the writer was bound to with `Writer::with_charset`.
567        writer: &'static str,
568    },
569
570    /// The interchange declares a character repertoire this crate cannot decode.
571    ///
572    /// `UNOX` (ISO 2022 code extension) and `KECA` (Korean) are stateful or
573    /// multi-byte in ways that break the byte-level delimiter scanning every
574    /// other repertoire allows.  They are reported rather than silently
575    /// mis-decoded.
576    #[error("character repertoire '{syntax_identifier}' is not supported")]
577    UnsupportedCharset {
578        /// The `UNB` S001 DE 0001 value that could not be handled.
579        syntax_identifier: String,
580    },
581
582    /// A [`SegmentLayout`][crate::SegmentLayout] was applied to a segment with a different tag.
583    ///
584    /// Passing the `NAD` definition to a `DTM` segment would resolve codes
585    /// against the wrong table, which is exactly the class of mistake that
586    /// code-addressed access exists to prevent — so it is rejected up front.
587    #[error("segment layout is for {expected}, but the segment is {actual}")]
588    SegmentLayoutMismatch {
589        /// Tag the layout describes.
590        expected: String,
591        /// Tag of the segment the layout was applied to.
592        actual: String,
593    },
594}
595
596impl From<std::io::Error> for EdifactError {
597    fn from(e: std::io::Error) -> Self {
598        Self::Io(IoError(e))
599    }
600}
601
602impl EdifactError {
603    /// Stable diagnostic code for this error variant.
604    #[must_use]
605    pub const fn stable_code(&self) -> &'static str {
606        match self {
607            Self::UnexpectedEof { .. } => "E001",
608            Self::InvalidDelimiter { .. } => "E002",
609            Self::InvalidText { .. } => "E003",
610            Self::MessageCountMismatch { .. } => "E004",
611            Self::SegmentCountMismatch { .. } => "E005",
612            Self::InvalidSegmentTag(_) => "E006",
613            Self::InvalidUna => "E007",
614            Self::MissingRequiredElement { .. } => "E008",
615            Self::InvalidUtf8 => "E009",
616            Self::Io(_) => "E010",
617            Self::InvalidSegmentForMessage { .. } => "E011",
618            Self::InvalidElementCount { .. } => "E012",
619            Self::InvalidComponentCount { .. } => "E013",
620            Self::InvalidCodeValue { .. } => "E014",
621            Self::MissingSegment { .. } => "E015",
622            Self::QualifierMismatch { .. } => "E016",
623            Self::ConditionalRequirementNotMet { .. } => "E017",
624            // E018 is permanently retired (was ValidationFailed, removed in 0.8.0)
625            Self::InvalidReleaseSequence { .. } => "E019",
626            Self::SegmentTooLong { .. } => "E020",
627            Self::MissingRequiredComponent { .. } => "E021",
628            Self::UnexpectedMessageType { .. } => "E022",
629            Self::InterchangeTooLarge { .. } => "E023",
630            Self::InvalidEventSequence { .. } => "E024",
631            Self::InvalidElementPosition => "E025",
632            Self::IncompatibleReleaseScopes { .. } => "E026",
633            Self::InvalidFieldValue { .. } => "E027",
634            Self::UnexpectedDataToken { .. } => "E028",
635            // E029 is permanently retired (was FunctionalGroupNotSupported, removed when
636            // full UNG/UNE support was added — functional groups are now parsed natively)
637            Self::ValidationErrors { .. } => "E030",
638            Self::UnrecognisedSyntaxIdentifier(_) => "E031",
639            Self::DuplicateReference { .. } => "E032",
640            Self::UnknownDataElement { .. } => "E033",
641            Self::AmbiguousDataElement { .. } => "E034",
642            Self::SegmentLayoutMismatch { .. } => "E035",
643            Self::LimitExceeded { .. } => "E036",
644            Self::RepetitionSeparatorNotDeclared => "E037",
645            Self::CharacterNotInRepertoire { .. } => "E038",
646            Self::UnsupportedCharset { .. } => "E039",
647            Self::NonFiniteNumber { .. } => "E040",
648            Self::CharacterRepertoireMismatch { .. } => "E041",
649        }
650    }
651
652    /// Stable recovery hint for common malformed input and validation cases.
653    #[must_use]
654    pub fn recovery_hint(&self) -> Option<&'static str> {
655        match self {
656            Self::UnexpectedEof { .. } => {
657                Some("Ensure every segment ends with the configured segment terminator")
658            }
659            Self::InvalidDelimiter { .. } => {
660                Some("Check UNA service string advice and delimiter bytes in the payload")
661            }
662            Self::InvalidText { .. } => {
663                Some("Input must be valid UTF-8 text for segment and element values")
664            }
665            Self::InvalidReleaseSequence { .. } => {
666                Some("Release character must escape one following byte; trailing '?' is invalid")
667            }
668            Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
669            Self::InvalidUna => Some(
670                "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
671            ),
672            Self::MissingRequiredElement { .. } => {
673                Some("Provide all mandatory elements for the segment per directory rules")
674            }
675            Self::MissingRequiredComponent { .. } => Some(
676                "Provide all mandatory components for the composite element per directory rules",
677            ),
678            Self::InvalidSegmentForMessage { .. } => {
679                Some("Remove unsupported segment or switch to the correct message type")
680            }
681            Self::InvalidElementCount { .. } => {
682                Some("Adjust the segment element count to the allowed min/max range")
683            }
684            Self::InvalidComponentCount { .. } => {
685                Some("Fix composite element arity to match the expected component count")
686            }
687            Self::InvalidCodeValue { .. } => {
688                Some("Use a value from the referenced code list for this element")
689            }
690            Self::MissingSegment { .. } => {
691                Some("Insert the required segment at the expected position")
692            }
693            Self::QualifierMismatch { .. } => {
694                Some("Set the segment qualifier to the expected value")
695            }
696            Self::ConditionalRequirementNotMet { .. } => {
697                Some("When the condition is met, include the conditionally required element")
698            }
699            Self::SegmentTooLong { limit, .. } => {
700                let _ = limit; // used in the error message; hint is generic
701                Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
702            }
703            Self::InvalidEventSequence { .. } => {
704                Some("Emit StartSegment before Element, and Element before ComponentElement")
705            }
706            Self::InvalidElementPosition => Some(
707                "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
708            ),
709            Self::IncompatibleReleaseScopes { .. } => Some(
710                "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
711            ),
712            Self::InvalidFieldValue { .. } => Some(
713                "Correct the field value to match the expected format or range for this element",
714            ),
715            Self::UnexpectedDataToken { .. } => Some(
716                "A data element appeared before any segment tag; check for partial writes or encoding corruption",
717            ),
718            Self::DuplicateReference { .. } => Some(
719                "Assign a unique control reference to every UNH (DE 0062) and UNG (DE 0048) within an interchange",
720            ),
721            Self::UnknownDataElement { .. } => Some(
722                "Check the data element identifier against the segment definition; the directory is the source of truth",
723            ),
724            Self::AmbiguousDataElement { .. } => Some(
725                "The code appears at more than one position; address the element positionally instead",
726            ),
727            Self::SegmentLayoutMismatch { .. } => {
728                Some("Resolve codes against the segment definition whose tag matches the segment")
729            }
730            Self::LimitExceeded { .. } => {
731                Some("Raise the corresponding ReaderConfig limit, or reject the input as oversized")
732            }
733            Self::RepetitionSeparatorNotDeclared => Some(
734                "Build the writer with Writer::with_una and a ServiceStringAdvice whose repetition_sep is set",
735            ),
736            Self::CharacterNotInRepertoire { .. } => Some(
737                "Transliterate the value into the declared repertoire, or declare a wider one in UNB S001 (UNOC for Latin-1, UNOY for UTF-8)",
738            ),
739            Self::UnsupportedCharset { .. } => Some(
740                "UNOX and KECA are not supported; ask the partner for UNOC or UNOY, or transcode the interchange before parsing",
741            ),
742            Self::NonFiniteNumber { .. } => Some(
743                "EDIFACT has no representation for NaN or infinity; check the calculation, or omit the element",
744            ),
745            Self::CharacterRepertoireMismatch { .. } => Some(
746                "Pass the writer's own syntax identifier to begin_interchange, or bind the writer to the repertoire the header declares",
747            ),
748            Self::ValidationErrors { .. }
749            | Self::MessageCountMismatch { .. }
750            | Self::SegmentCountMismatch { .. }
751            | Self::UnexpectedMessageType { .. }
752            | Self::InterchangeTooLarge { .. }
753            | Self::UnrecognisedSyntaxIdentifier(_)
754            | Self::InvalidUtf8
755            | Self::Io(_) => None,
756        }
757    }
758}
759
760#[cfg(feature = "diagnostics")]
761#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
762impl miette::Diagnostic for EdifactError {
763    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
764        Some(Box::new(self.stable_code()))
765    }
766
767    fn severity(&self) -> Option<miette::Severity> {
768        match self {
769            Self::InvalidCodeValue { .. }
770            | Self::InvalidComponentCount { .. }
771            | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
772            _ => Some(miette::Severity::Error),
773        }
774    }
775
776    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
777        match self {
778            // Static text — no allocation needed.
779            Self::InvalidUna => Some(Box::new(
780                "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
781            )),
782            Self::InvalidUtf8 => Some(Box::new(
783                "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
784            )),
785            // Dynamic help text.
786            Self::UnexpectedEof { offset } => Some(Box::new(format!(
787                "Check that all segments are terminated with the segment terminator (usually '). \
788                 Reached end at offset {offset}",
789            ))),
790            Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
791                "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
792                 Check UNA configuration",
793            ))),
794            Self::InvalidText { offset } => Some(Box::new(format!(
795                "The byte sequence at offset {offset} contains invalid UTF-8. \
796                 Ensure input is valid UTF-8",
797            ))),
798            Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
799                "Release character at offset {offset} is dangling. \
800                 Ensure '?' is followed by an escaped byte",
801            ))),
802            Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
803                "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
804                 Check the UNZ message count",
805            ))),
806            Self::SegmentCountMismatch {
807                expected,
808                actual,
809                message_ref,
810            } => Some(Box::new(format!(
811                "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
812                 Check the UNT segment count",
813            ))),
814            Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
815                "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
816            ))),
817            Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
818                "Segment {tag} requires element at index {element_index}",
819            ))),
820            Self::MissingRequiredComponent {
821                tag,
822                element_index,
823                component_index,
824            } => Some(Box::new(format!(
825                "Segment {tag} element {element_index} requires component at index {component_index}",
826            ))),
827            Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
828            Self::InvalidSegmentForMessage {
829                tag, message_type, ..
830            } => Some(Box::new(format!(
831                "Segment {tag} should not appear in a {message_type} message. \
832                 Check the directory definition",
833            ))),
834            Self::InvalidElementCount {
835                tag,
836                min,
837                max,
838                actual,
839                ..
840            } => Some(Box::new(format!(
841                "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
842                 Check segment structure",
843            ))),
844            Self::InvalidComponentCount {
845                tag,
846                element_index,
847                expected,
848                actual,
849                ..
850            } => Some(Box::new(format!(
851                "In segment {tag}, element {element_index} should have {expected} components \
852                     but has {actual}. Check element structure",
853            ))),
854            Self::InvalidCodeValue {
855                tag,
856                element_index,
857                value,
858                code_list,
859                ..
860            } => Some(Box::new(format!(
861                "Value '{value}' in segment {tag} element {element_index} is not in the \
862                     {code_list} code list. Check the directory for valid codes",
863            ))),
864            Self::MissingSegment {
865                tag,
866                expected_position,
867            } => Some(Box::new(format!(
868                "Segment {tag} is required at position {expected_position} but is missing. \
869                 Add this segment to the message",
870            ))),
871            Self::QualifierMismatch {
872                tag,
873                actual,
874                expected,
875                ..
876            } => Some(Box::new(format!(
877                "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
878                 Check the segment's first component",
879            ))),
880            Self::ConditionalRequirementNotMet {
881                tag,
882                element_index,
883                condition,
884                ..
885            } => Some(Box::new(format!(
886                "In segment {tag}, element {element_index} is conditionally required when: \
887                     {condition}. Check if the condition is met",
888            ))),
889            Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
890                "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
891                 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
892                 or verify the input for a missing segment terminator",
893            ))),
894            Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
895                "No handler was registered for message type '{message_type}'. \
896                 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
897            ))),
898            Self::InterchangeTooLarge { count } => Some(Box::new(format!(
899                "Interchange contains {count} items which exceeds the u32::MAX limit. \
900                 This is an extremely unusual input; verify the message is not corrupted.",
901            ))),
902            Self::InvalidEventSequence { message } => Some(Box::new(format!(
903                "Event sequence violation: {message}. \
904                 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
905            ))),
906            Self::InvalidElementPosition => Some(Box::new(
907                "Element positions must be >= 1 (one-based). \
908                 Ensure no OwnedElementRef is constructed with position == 0",
909            )),
910            Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
911                "Release scope {current:?} and {incoming:?} are incompatible. \
912                 Only compose ProfileRulePack values that share the same release scope, \
913                 or where at most one carries a release scope",
914            ))),
915            Self::InvalidFieldValue {
916                tag,
917                element_index,
918                value,
919            } => Some(Box::new(format!(
920                "Segment {tag} element {element_index} has invalid value '{value}'. \
921                 Check the expected format or range for this field",
922            ))),
923            Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
924                "Data element at offset {offset} appeared before any segment tag. \
925                 Check for partial writes or encoding corruption",
926            ))),
927            Self::ValidationErrors { error_count, .. } => Some(Box::new(format!(
928                "Validation found {error_count} error(s). Inspect the ValidationReport for details",
929            ))),
930            Self::UnrecognisedSyntaxIdentifier(id) => Some(Box::new(format!(
931                "Syntax identifier '{id}' is not defined in ISO 9735-1. \
932                 Valid values are UNOA, UNOB, UNOC, UNOD, UNOE, UNOF (or KECA for KEC-A profile)",
933            ))),
934            Self::DuplicateReference { tag, reference, .. } => Some(Box::new(format!(
935                "Reference '{reference}' is used by more than one {tag} in this interchange; \
936                 each must be unique so receivers can address messages unambiguously",
937            ))),
938            Self::UnknownDataElement { tag, data_element } => Some(Box::new(format!(
939                "Segment {tag} does not define data element {data_element}. \
940                 Check the identifier against the directory definition for {tag}",
941            ))),
942            Self::AmbiguousDataElement { tag, data_element } => Some(Box::new(format!(
943                "Segment {tag} defines data element {data_element} at more than one position, \
944                 so code-addressed access cannot pick one; use a positional accessor",
945            ))),
946            Self::SegmentLayoutMismatch { expected, actual } => Some(Box::new(format!(
947                "The supplied layout describes segment {expected} but was applied to {actual}. \
948                 Look up the definition by the segment's own tag",
949            ))),
950            Self::RepetitionSeparatorNotDeclared => Some(Box::new(
951                "UNA position 7 holds the space \"not used\" sentinel, so repeating data \
952                 elements cannot be expressed. Use Writer::with_una with a repetition_sep",
953            )),
954            Self::CharacterNotInRepertoire {
955                charset,
956                character,
957                offset,
958            } => Some(Box::new(format!(
959                "The character {character:?} at offset {offset} has no representation in {charset}. \
960                 Transliterate it, or declare a wider repertoire in UNB S001 DE 0001",
961            ))),
962            Self::UnsupportedCharset { syntax_identifier } => Some(Box::new(format!(
963                "'{syntax_identifier}' is stateful or multi-byte, so byte-level delimiter scanning \
964                 would be unsound. Transcode the interchange to UNOC or UNOY before parsing",
965            ))),
966            Self::CharacterRepertoireMismatch { declared, writer } => Some(Box::new(format!(
967                "The UNB declares {declared} but the writer encodes {writer}. \
968                 The receiver would decode the body with the wrong table",
969            ))),
970            Self::NonFiniteNumber { value } => Some(Box::new(format!(
971                "The value {value} is not finite. EDIFACT numeric data elements have no \
972                 representation for NaN or infinity",
973            ))),
974            Self::LimitExceeded { limit, max } => Some(Box::new(format!(
975                "The input exceeds the configured {limit} limit of {max}. \
976                 Raise it via ReaderConfig if the input is legitimate, or reject the input",
977            ))),
978        }
979    }
980}
981
982// ── validation report ─────────────────────────────────────────────────────────
983
984pub use crate::report::ValidationReport;
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    #[test]
991    fn recovery_hint_exists_for_common_malformed_cases() {
992        let err = EdifactError::InvalidReleaseSequence { offset: 10 };
993        assert!(err.recovery_hint().is_some());
994
995        let err = EdifactError::InvalidCodeValue {
996            tag: "BGM".to_owned(),
997            element_index: 0,
998            value: "X".to_owned(),
999            code_list: "1001".to_owned(),
1000            span: Span::new(0, 9),
1001            suggestion: None,
1002        };
1003        assert!(err.recovery_hint().is_some());
1004    }
1005}