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