Skip to main content

edifact_rs/
error.rs

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