1use thiserror::Error;
2
3#[derive(Debug)]
7pub struct IoError(pub std::io::Error);
8
9impl PartialEq for IoError {
10 fn eq(&self, other: &Self) -> bool {
17 self.0.kind() == other.0.kind()
18 }
19}
20
21impl std::fmt::Display for IoError {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 self.0.fmt(f)
24 }
25}
26
27impl std::error::Error for IoError {
28 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
29 self.0.source()
30 }
31}
32
33impl From<std::io::Error> for IoError {
34 fn from(e: std::io::Error) -> Self {
35 Self(e)
36 }
37}
38
39#[derive(Debug, Error, PartialEq)]
46#[non_exhaustive]
47pub enum EdifactError {
48 #[error("unexpected end of input at byte offset {offset}")]
53 UnexpectedEof {
54 offset: usize,
56 },
57
58 #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
63 InvalidDelimiter {
64 byte: u8,
66 offset: usize,
68 },
69
70 #[error("invalid EDIFACT text at byte offset {offset}")]
75 InvalidText {
76 offset: usize,
78 },
79
80 #[error("invalid release sequence at byte offset {offset}: dangling release character")]
85 InvalidReleaseSequence {
86 offset: usize,
88 },
89
90 #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
95 MessageCountMismatch {
96 expected: u32,
98 actual: u32,
100 },
101
102 #[error(
107 "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
108 )]
109 SegmentCountMismatch {
110 expected: u32,
112 actual: u32,
114 message_ref: String,
116 },
117
118 #[error("invalid segment tag {0:?}")]
122 InvalidSegmentTag(String),
123
124 #[error("invalid UNA service string advice")]
132 InvalidUna,
133
134 #[error("missing required element {element_index} in segment {tag}")]
139 MissingRequiredElement {
140 tag: String,
142 element_index: usize,
144 },
145
146 #[error(
150 "missing required component {component_index} in element {element_index} of segment {tag}"
151 )]
152 MissingRequiredComponent {
153 tag: String,
155 element_index: usize,
157 component_index: usize,
159 },
160
161 #[error("serialized output contains invalid UTF-8")]
166 InvalidUtf8,
167
168 #[error(transparent)]
170 Io(#[from] IoError),
171
172 #[error("segment {tag} is not valid for message type {message_type}")]
177 InvalidSegmentForMessage {
178 tag: String,
180 message_type: String,
182 offset: usize,
184 },
185
186 #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
190 InvalidElementCount {
191 tag: String,
193 min: usize,
195 max: usize,
197 actual: usize,
199 offset: usize,
201 },
202
203 #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
207 InvalidComponentCount {
208 tag: String,
210 element_index: usize,
212 expected: u8,
214 actual: u8,
216 offset: usize,
218 },
219
220 #[error(
225 "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
226 )]
227 InvalidCodeValue {
228 tag: String,
230 element_index: usize,
232 value: String,
234 code_list: String,
236 offset: usize,
238 suggestion: Option<&'static str>,
240 },
241
242 #[error("required segment {tag} is missing from message (position {expected_position})")]
246 MissingSegment {
247 tag: String,
249 expected_position: String,
251 },
252
253 #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
257 QualifierMismatch {
258 tag: String,
260 actual: String,
262 expected: String,
264 offset: usize,
266 },
267
268 #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
273 ConditionalRequirementNotMet {
274 tag: String,
276 element_index: usize,
278 condition: String,
280 offset: usize,
282 },
283
284 #[error("validation failed with {error_count} issue(s); first issue: {first_message}")]
286 ValidationFailed {
287 error_count: usize,
289 first_message: String,
291 },
292
293 #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
302 SegmentTooLong {
303 offset: usize,
305 limit: usize,
307 },
308
309 #[error("no handler registered for message type {message_type}")]
315 UnexpectedMessageType {
316 message_type: String,
318 },
319
320 #[error("interchange too large: count {count} exceeds u32::MAX")]
327 InterchangeTooLarge {
328 count: u64,
330 },
331
332 #[error("invalid event sequence: {message}")]
340 InvalidEventSequence {
341 message: &'static str,
343 },
344
345 #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
351 InvalidElementPosition,
352
353 #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
360 IncompatibleReleaseScopes {
361 current: String,
363 incoming: String,
365 },
366
367 #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
373 InvalidFieldValue {
374 tag: String,
376 element_index: usize,
378 value: String,
380 },
381
382 #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
391 UnexpectedDataToken {
392 offset: usize,
394 },
395
396 #[error(
402 "functional group segments (UNG/UNE) at byte offset {offset} are not supported; \
403 strip them before calling validate_envelope"
404 )]
405 FunctionalGroupNotSupported {
406 offset: usize,
408 },
409}
410
411impl From<std::io::Error> for EdifactError {
412 fn from(e: std::io::Error) -> Self {
413 Self::Io(IoError(e))
414 }
415}
416
417impl EdifactError {
418 #[must_use]
420 pub const fn stable_code(&self) -> &'static str {
421 match self {
422 Self::UnexpectedEof { .. } => "E001",
423 Self::InvalidDelimiter { .. } => "E002",
424 Self::InvalidText { .. } => "E003",
425 Self::MessageCountMismatch { .. } => "E004",
426 Self::SegmentCountMismatch { .. } => "E005",
427 Self::InvalidSegmentTag(_) => "E006",
428 Self::InvalidUna => "E007",
429 Self::MissingRequiredElement { .. } => "E008",
430 Self::InvalidUtf8 => "E009",
431 Self::Io(_) => "E010",
432 Self::InvalidSegmentForMessage { .. } => "E011",
433 Self::InvalidElementCount { .. } => "E012",
434 Self::InvalidComponentCount { .. } => "E013",
435 Self::InvalidCodeValue { .. } => "E014",
436 Self::MissingSegment { .. } => "E015",
437 Self::QualifierMismatch { .. } => "E016",
438 Self::ConditionalRequirementNotMet { .. } => "E017",
439 Self::ValidationFailed { .. } => "E018",
440 Self::InvalidReleaseSequence { .. } => "E019",
441 Self::SegmentTooLong { .. } => "E020",
442 Self::MissingRequiredComponent { .. } => "E021",
443 Self::UnexpectedMessageType { .. } => "E022",
444 Self::InterchangeTooLarge { .. } => "E023",
445 Self::InvalidEventSequence { .. } => "E024",
446 Self::InvalidElementPosition => "E025",
447 Self::IncompatibleReleaseScopes { .. } => "E026",
448 Self::InvalidFieldValue { .. } => "E027",
449 Self::UnexpectedDataToken { .. } => "E028",
450 Self::FunctionalGroupNotSupported { .. } => "E029",
451 }
452 }
453
454 #[must_use]
456 pub fn recovery_hint(&self) -> Option<&'static str> {
457 match self {
458 Self::UnexpectedEof { .. } => {
459 Some("Ensure every segment ends with the configured segment terminator")
460 }
461 Self::InvalidDelimiter { .. } => {
462 Some("Check UNA service string advice and delimiter bytes in the payload")
463 }
464 Self::InvalidText { .. } => {
465 Some("Input must be valid UTF-8 text for segment and element values")
466 }
467 Self::InvalidReleaseSequence { .. } => {
468 Some("Release character must escape one following byte; trailing '?' is invalid")
469 }
470 Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
471 Self::InvalidUna => Some(
472 "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
473 ),
474 Self::MissingRequiredElement { .. } => {
475 Some("Provide all mandatory elements for the segment per directory rules")
476 }
477 Self::MissingRequiredComponent { .. } => Some(
478 "Provide all mandatory components for the composite element per directory rules",
479 ),
480 Self::InvalidSegmentForMessage { .. } => {
481 Some("Remove unsupported segment or switch to the correct message type")
482 }
483 Self::InvalidElementCount { .. } => {
484 Some("Adjust the segment element count to the allowed min/max range")
485 }
486 Self::InvalidComponentCount { .. } => {
487 Some("Fix composite element arity to match the expected component count")
488 }
489 Self::InvalidCodeValue { .. } => {
490 Some("Use a value from the referenced code list for this element")
491 }
492 Self::MissingSegment { .. } => {
493 Some("Insert the required segment at the expected position")
494 }
495 Self::QualifierMismatch { .. } => {
496 Some("Set the segment qualifier to the expected value")
497 }
498 Self::ConditionalRequirementNotMet { .. } => {
499 Some("When the condition is met, include the conditionally required element")
500 }
501 Self::SegmentTooLong { limit, .. } => {
502 let _ = limit; Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
504 }
505 Self::InvalidEventSequence { .. } => {
506 Some("Emit StartSegment before Element, and Element before ComponentElement")
507 }
508 Self::InvalidElementPosition => Some(
509 "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
510 ),
511 Self::IncompatibleReleaseScopes { .. } => Some(
512 "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
513 ),
514 Self::InvalidFieldValue { .. } => Some(
515 "Correct the field value to match the expected format or range for this element",
516 ),
517 Self::UnexpectedDataToken { .. } => Some(
518 "A data element appeared before any segment tag; check for partial writes or encoding corruption",
519 ),
520 Self::FunctionalGroupNotSupported { .. } => Some(
521 "Strip UNG/UNE segments before calling validate_envelope, or process the interchange as raw segments",
522 ),
523 Self::ValidationFailed { .. }
524 | Self::MessageCountMismatch { .. }
525 | Self::SegmentCountMismatch { .. }
526 | Self::UnexpectedMessageType { .. }
527 | Self::InterchangeTooLarge { .. }
528 | Self::InvalidUtf8
529 | Self::Io(_) => None,
530 }
531 }
532}
533
534#[cfg(feature = "diagnostics")]
535#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
536impl miette::Diagnostic for EdifactError {
537 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
538 Some(Box::new(self.stable_code()))
539 }
540
541 fn severity(&self) -> Option<miette::Severity> {
542 match self {
543 Self::InvalidCodeValue { .. }
544 | Self::InvalidComponentCount { .. }
545 | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
546 _ => Some(miette::Severity::Error),
547 }
548 }
549
550 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
551 match self {
552 Self::InvalidUna => Some(Box::new(
554 "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
555 )),
556 Self::InvalidUtf8 => Some(Box::new(
557 "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
558 )),
559 Self::UnexpectedEof { offset } => Some(Box::new(format!(
561 "Check that all segments are terminated with the segment terminator (usually '). \
562 Reached end at offset {offset}",
563 ))),
564 Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
565 "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
566 Check UNA configuration",
567 ))),
568 Self::InvalidText { offset } => Some(Box::new(format!(
569 "The byte sequence at offset {offset} contains invalid UTF-8. \
570 Ensure input is valid UTF-8",
571 ))),
572 Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
573 "Release character at offset {offset} is dangling. \
574 Ensure '?' is followed by an escaped byte",
575 ))),
576 Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
577 "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
578 Check the UNZ message count",
579 ))),
580 Self::SegmentCountMismatch {
581 expected,
582 actual,
583 message_ref,
584 } => Some(Box::new(format!(
585 "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
586 Check the UNT segment count",
587 ))),
588 Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
589 "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
590 ))),
591 Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
592 "Segment {tag} requires element at index {element_index}",
593 ))),
594 Self::MissingRequiredComponent {
595 tag,
596 element_index,
597 component_index,
598 } => Some(Box::new(format!(
599 "Segment {tag} element {element_index} requires component at index {component_index}",
600 ))),
601 Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
602 Self::InvalidSegmentForMessage {
603 tag, message_type, ..
604 } => Some(Box::new(format!(
605 "Segment {tag} should not appear in a {message_type} message. \
606 Check the directory definition",
607 ))),
608 Self::InvalidElementCount {
609 tag,
610 min,
611 max,
612 actual,
613 ..
614 } => Some(Box::new(format!(
615 "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
616 Check segment structure",
617 ))),
618 Self::InvalidComponentCount {
619 tag,
620 element_index,
621 expected,
622 actual,
623 ..
624 } => Some(Box::new(format!(
625 "In segment {tag}, element {element_index} should have {expected} components \
626 but has {actual}. Check element structure",
627 ))),
628 Self::InvalidCodeValue {
629 tag,
630 element_index,
631 value,
632 code_list,
633 ..
634 } => Some(Box::new(format!(
635 "Value '{value}' in segment {tag} element {element_index} is not in the \
636 {code_list} code list. Check the directory for valid codes",
637 ))),
638 Self::MissingSegment {
639 tag,
640 expected_position,
641 } => Some(Box::new(format!(
642 "Segment {tag} is required at position {expected_position} but is missing. \
643 Add this segment to the message",
644 ))),
645 Self::QualifierMismatch {
646 tag,
647 actual,
648 expected,
649 ..
650 } => Some(Box::new(format!(
651 "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
652 Check the segment's first component",
653 ))),
654 Self::ConditionalRequirementNotMet {
655 tag,
656 element_index,
657 condition,
658 ..
659 } => Some(Box::new(format!(
660 "In segment {tag}, element {element_index} is conditionally required when: \
661 {condition}. Check if the condition is met",
662 ))),
663 Self::ValidationFailed {
664 error_count,
665 first_message,
666 } => Some(Box::new(format!(
667 "Validation found {error_count} issue(s). Start by fixing: {first_message}",
668 ))),
669 Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
670 "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
671 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
672 or verify the input for a missing segment terminator",
673 ))),
674 Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
675 "No handler was registered for message type '{message_type}'. \
676 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
677 ))),
678 Self::InterchangeTooLarge { count } => Some(Box::new(format!(
679 "Interchange contains {count} items which exceeds the u32::MAX limit. \
680 This is an extremely unusual input; verify the message is not corrupted.",
681 ))),
682 Self::InvalidEventSequence { message } => Some(Box::new(format!(
683 "Event sequence violation: {message}. \
684 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
685 ))),
686 Self::InvalidElementPosition => Some(Box::new(
687 "Element positions must be >= 1 (one-based). \
688 Ensure no OwnedElementRef is constructed with position == 0",
689 )),
690 Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
691 "Release scope {current:?} and {incoming:?} are incompatible. \
692 Only compose ProfileRulePack values that share the same release scope, \
693 or where at most one carries a release scope",
694 ))),
695 Self::InvalidFieldValue {
696 tag,
697 element_index,
698 value,
699 } => Some(Box::new(format!(
700 "Segment {tag} element {element_index} has invalid value '{value}'. \
701 Check the expected format or range for this field",
702 ))),
703 Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
704 "Data element at offset {offset} appeared before any segment tag. \
705 Check for partial writes or encoding corruption",
706 ))),
707 Self::FunctionalGroupNotSupported { offset } => Some(Box::new(format!(
708 "Functional group segment (UNG/UNE) found at offset {offset}. \
709 Strip UNG/UNE wrappers before calling validate_envelope",
710 ))),
711 }
712 }
713}
714
715#[non_exhaustive]
722#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
723pub enum ValidationSeverity {
724 Critical,
726 Error,
728 Warning,
730 Info,
732}
733
734impl std::fmt::Display for ValidationSeverity {
735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
736 match self {
737 Self::Critical => f.write_str("critical"),
738 Self::Error => f.write_str("error"),
739 Self::Warning => f.write_str("warning"),
740 Self::Info => f.write_str("info"),
741 }
742 }
743}
744
745#[derive(Debug, Clone, PartialEq)]
747pub struct ValidationIssue {
748 pub error_code: Option<&'static str>,
750 pub severity: ValidationSeverity,
752 pub message: String,
754 pub offset: Option<usize>,
756 pub segment_tag: Option<String>,
758 pub rule_id: Option<String>,
760 pub element_index: Option<u8>,
765 pub component_index: Option<u8>,
770 pub segment_occurrence: Option<u16>,
776 pub message_ref: Option<String>,
783 pub suggestion: Option<String>,
785}
786
787impl ValidationIssue {
788 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
790 Self {
791 error_code: None,
792 severity,
793 message: message.into(),
794 offset: None,
795 segment_tag: None,
796 rule_id: None,
797 element_index: None,
798 component_index: None,
799 segment_occurrence: None,
800 message_ref: None,
801 suggestion: None,
802 }
803 }
804
805 pub fn with_error_code(mut self, code: &'static str) -> Self {
807 self.error_code = Some(code);
808 self
809 }
810
811 pub fn with_offset(mut self, offset: usize) -> Self {
813 self.offset = Some(offset);
814 self
815 }
816
817 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
819 self.segment_tag = Some(tag.into());
820 self
821 }
822
823 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
825 self.rule_id = Some(rule_id.into());
826 self
827 }
828
829 pub fn with_element_index(mut self, element_index: u8) -> Self {
831 self.element_index = Some(element_index);
832 self
833 }
834
835 pub fn with_component_index(mut self, component_index: u8) -> Self {
837 self.component_index = Some(component_index);
838 self
839 }
840
841 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
843 self.suggestion = Some(suggestion.into());
844 self
845 }
846
847 pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
852 self.segment_occurrence = Some(occurrence);
853 self
854 }
855
856 pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
861 self.message_ref = Some(message_ref.into());
862 self
863 }
864
865 #[must_use]
867 pub fn severity_label(&self) -> &'static str {
868 match self.severity {
869 ValidationSeverity::Critical => "CRITICAL",
870 ValidationSeverity::Error => "ERROR",
871 ValidationSeverity::Warning => "WARNING",
872 ValidationSeverity::Info => "INFO",
873 }
874 }
875}
876
877impl std::fmt::Display for ValidationIssue {
878 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
879 write!(f, "[{}] {}", self.severity_label(), self.message)
880 }
881}
882
883impl std::error::Error for ValidationIssue {}
884
885#[derive(Debug, Clone, Default)]
889pub struct ValidationReport {
890 pub(crate) errors: Vec<ValidationIssue>,
892 pub(crate) warnings: Vec<ValidationIssue>,
894 pub(crate) infos: Vec<ValidationIssue>,
896}
897
898impl ValidationReport {
899 pub fn errors(&self) -> &[ValidationIssue] {
901 &self.errors
902 }
903
904 pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
906 &mut self.errors
907 }
908
909 pub fn warnings(&self) -> &[ValidationIssue] {
911 &self.warnings
912 }
913
914 pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
916 &mut self.warnings
917 }
918
919 pub fn infos(&self) -> &[ValidationIssue] {
921 &self.infos
922 }
923
924 pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
926 &mut self.infos
927 }
928 pub fn add_error(&mut self, issue: ValidationIssue) {
930 self.errors.push(issue);
931 }
932
933 pub fn add_warning(&mut self, issue: ValidationIssue) {
935 self.warnings.push(issue);
936 }
937
938 pub fn add_info(&mut self, issue: ValidationIssue) {
940 self.infos.push(issue);
941 }
942
943 pub fn has_errors(&self) -> bool {
945 !self.errors().is_empty()
946 }
947
948 pub fn has_warnings(&self) -> bool {
950 !self.warnings().is_empty()
951 }
952
953 pub fn total_issues(&self) -> usize {
955 self.errors().len() + self.warnings().len() + self.infos().len()
956 }
957
958 pub fn is_valid(&self) -> bool {
960 self.errors().is_empty()
961 }
962
963 pub fn result(self) -> Result<Self, Self> {
969 if self.is_valid() { Ok(self) } else { Err(self) }
970 }
971
972 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
974 self.errors()
975 .iter()
976 .chain(self.warnings().iter())
977 .chain(self.infos().iter())
978 }
979
980 pub fn has_any_issues(&self) -> bool {
982 !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
983 }
984
985 pub fn merge(&mut self, mut other: ValidationReport) {
990 self.errors.append(&mut other.errors);
991 self.warnings.append(&mut other.warnings);
992 self.infos.append(&mut other.infos);
993 }
994
995 pub fn issues_for_rule_id<'a>(
1000 &'a self,
1001 rule_id: &'a str,
1002 ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
1003 self.iter_issues()
1004 .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
1005 }
1006
1007 fn filter_report<F>(&self, pred: F) -> Self
1009 where
1010 F: Fn(&ValidationIssue) -> bool,
1011 {
1012 Self {
1013 errors: self.errors().iter().filter(|i| pred(i)).cloned().collect(),
1014 warnings: self
1015 .warnings()
1016 .iter()
1017 .filter(|i| pred(i))
1018 .cloned()
1019 .collect(),
1020 infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
1021 }
1022 }
1023
1024 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
1026 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
1027 }
1028
1029 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
1031 self.filter_report(|issue| {
1032 issue
1033 .rule_id
1034 .as_deref()
1035 .is_some_and(|id| id.starts_with(prefix))
1036 })
1037 }
1038
1039 pub fn for_segment(&self, segment_tag: &str) -> Self {
1063 self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
1064 }
1065
1066 pub fn render_deterministic(&self) -> String {
1068 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
1069 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
1070 refs.sort_by(|left, right| {
1071 left.offset
1072 .unwrap_or(usize::MAX)
1073 .cmp(&right.offset.unwrap_or(usize::MAX))
1074 .then_with(|| {
1075 left.segment_tag
1076 .as_deref()
1077 .unwrap_or("")
1078 .cmp(right.segment_tag.as_deref().unwrap_or(""))
1079 })
1080 .then_with(|| {
1081 left.rule_id
1082 .as_deref()
1083 .unwrap_or("")
1084 .cmp(right.rule_id.as_deref().unwrap_or(""))
1085 })
1086 .then_with(|| {
1087 left.element_index
1088 .unwrap_or(u8::MAX)
1089 .cmp(&right.element_index.unwrap_or(u8::MAX))
1090 })
1091 .then_with(|| {
1092 left.component_index
1093 .unwrap_or(u8::MAX)
1094 .cmp(&right.component_index.unwrap_or(u8::MAX))
1095 })
1096 .then_with(|| {
1097 left.error_code
1098 .unwrap_or("")
1099 .cmp(right.error_code.unwrap_or(""))
1100 })
1101 .then_with(|| left.message.cmp(&right.message))
1102 });
1103 refs
1104 }
1105
1106 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
1107 use std::fmt::Write as _;
1108 out.push_str(" - ");
1109 out.push_str(&issue.message);
1110 if let Some(code) = issue.error_code {
1111 out.push_str(" [");
1112 out.push_str(code);
1113 out.push(']');
1114 }
1115 if let Some(seg) = &issue.segment_tag {
1116 out.push_str(" [segment=");
1117 out.push_str(seg);
1118 out.push(']');
1119 }
1120 if let Some(rule_id) = &issue.rule_id {
1121 out.push_str(" [rule=");
1122 out.push_str(rule_id);
1123 out.push(']');
1124 }
1125 if let Some(element_index) = issue.element_index {
1126 write!(out, " [element={element_index}]").ok();
1127 }
1128 if let Some(component_index) = issue.component_index {
1129 write!(out, " [component={component_index}]").ok();
1130 }
1131 if let Some(offset) = issue.offset {
1132 write!(out, " [offset={offset}]").ok();
1133 }
1134 if let Some(suggestion) = &issue.suggestion {
1135 out.push_str(" [hint=");
1136 out.push_str(suggestion);
1137 out.push(']');
1138 }
1139 }
1140
1141 use std::fmt::Write as _;
1142 let mut out = String::from("Validation Report:");
1143 let errors = sorted_refs(self.errors());
1144 let warnings = sorted_refs(self.warnings());
1145 let infos = sorted_refs(self.infos());
1146
1147 if !errors.is_empty() {
1148 write!(out, "\n Errors ({})", errors.len()).ok();
1149 for issue in &errors {
1150 out.push('\n');
1151 render_issue_line(&mut out, issue);
1152 }
1153 }
1154 if !warnings.is_empty() {
1155 write!(out, "\n Warnings ({})", warnings.len()).ok();
1156 for issue in &warnings {
1157 out.push('\n');
1158 render_issue_line(&mut out, issue);
1159 }
1160 }
1161 if !infos.is_empty() {
1162 write!(out, "\n Info ({})", infos.len()).ok();
1163 for issue in &infos {
1164 out.push('\n');
1165 render_issue_line(&mut out, issue);
1166 }
1167 }
1168
1169 out
1170 }
1171}
1172
1173#[cfg(feature = "diagnostics")]
1174impl miette::Diagnostic for ValidationReport {
1175 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1176 Some(Box::new("VALIDATION"))
1177 }
1178
1179 fn severity(&self) -> Option<miette::Severity> {
1180 if self.has_errors() {
1181 Some(miette::Severity::Error)
1182 } else if self.has_warnings() {
1183 Some(miette::Severity::Warning)
1184 } else {
1185 Some(miette::Severity::Advice)
1186 }
1187 }
1188
1189 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1190 let msg = format!(
1191 "Validation found {} error(s), {} warning(s), {} info(s)",
1192 self.errors().len(),
1193 self.warnings().len(),
1194 self.infos().len()
1195 );
1196 Some(Box::new(msg))
1197 }
1198}
1199
1200impl std::fmt::Display for ValidationReport {
1201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1202 write!(f, "{}", self.render_deterministic())
1203 }
1204}
1205
1206impl std::error::Error for ValidationReport {}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211
1212 #[test]
1213 fn validation_report_collects_errors() {
1214 let mut report = ValidationReport::default();
1215 report.add_error(
1216 ValidationIssue::new(ValidationSeverity::Error, "Test error")
1217 .with_segment("BGM")
1218 .with_offset(42),
1219 );
1220 report.add_warning(ValidationIssue::new(
1221 ValidationSeverity::Warning,
1222 "Test warning",
1223 ));
1224
1225 assert!(report.has_errors());
1226 assert!(report.has_warnings());
1227 assert_eq!(report.total_issues(), 2);
1228 assert!(!report.is_valid());
1229 }
1230
1231 #[test]
1232 fn validation_report_result_conversion() {
1233 let mut report = ValidationReport::default();
1234 report.add_error(ValidationIssue::new(
1235 ValidationSeverity::Error,
1236 "Critical issue",
1237 ));
1238
1239 let result = report.result();
1240 assert!(result.is_err());
1241 }
1242
1243 #[test]
1244 fn validation_report_passes_when_no_errors() {
1245 let mut report = ValidationReport::default();
1246 report.add_warning(ValidationIssue::new(
1247 ValidationSeverity::Warning,
1248 "Just a warning",
1249 ));
1250
1251 assert!(report.is_valid());
1252 assert!(report.result().is_ok());
1253 }
1254
1255 #[test]
1256 fn validation_issue_builder() {
1257 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1258 .with_error_code("E013")
1259 .with_offset(100)
1260 .with_segment("NAD")
1261 .with_rule_id("DEMO-P001")
1262 .with_element_index(1)
1263 .with_component_index(2)
1264 .with_suggestion("Check element count");
1265
1266 assert_eq!(issue.error_code, Some("E013"));
1267 assert_eq!(issue.message, "test message");
1268 assert_eq!(issue.offset, Some(100));
1269 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1270 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1271 assert_eq!(issue.element_index, Some(1));
1272 assert_eq!(issue.component_index, Some(2));
1273 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1274 }
1275
1276 #[test]
1277 fn validation_report_display() {
1278 let mut report = ValidationReport::default();
1279 report.add_error(
1280 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1281 .with_error_code("E011")
1282 .with_offset(8),
1283 );
1284 report.add_warning(ValidationIssue::new(
1285 ValidationSeverity::Warning,
1286 "Warning 1",
1287 ));
1288 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1289
1290 let display_str = format!("{}", report);
1291 assert!(display_str.contains("Errors (1)"));
1292 assert!(display_str.contains("Warnings (1)"));
1293 assert!(display_str.contains("Info (1)"));
1294 assert!(display_str.contains("[E011]"));
1295 }
1296
1297 #[test]
1298 fn validation_report_render_is_deterministic() {
1299 let mut report = ValidationReport::default();
1300 report.add_error(
1301 ValidationIssue::new(ValidationSeverity::Error, "later")
1302 .with_segment("BGM")
1303 .with_offset(20),
1304 );
1305 report.add_error(
1306 ValidationIssue::new(ValidationSeverity::Error, "earlier")
1307 .with_segment("UNH")
1308 .with_offset(1),
1309 );
1310
1311 let rendered = report.render_deterministic();
1312 let first = rendered.find("earlier").expect("missing first issue");
1313 let second = rendered.find("later").expect("missing second issue");
1314 assert!(first < second, "expected deterministic sort by offset");
1315 }
1316
1317 #[test]
1318 fn recovery_hint_exists_for_common_malformed_cases() {
1319 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1320 assert!(err.recovery_hint().is_some());
1321
1322 let err = EdifactError::InvalidCodeValue {
1323 tag: "BGM".to_owned(),
1324 element_index: 0,
1325 value: "X".to_owned(),
1326 code_list: "1001".to_owned(),
1327 offset: 0,
1328 suggestion: None,
1329 };
1330 assert!(err.recovery_hint().is_some());
1331 }
1332
1333 #[test]
1334 fn validation_report_can_filter_by_rule_id() {
1335 let mut report = ValidationReport::default();
1336 report.add_error(
1337 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1338 .with_rule_id("ORDERS-P001"),
1339 );
1340 report.add_warning(
1341 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1342 .with_rule_id("INVOIC-P001"),
1343 );
1344 report.add_info(
1345 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1346 .with_rule_id("ORDERS-P002"),
1347 );
1348
1349 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1350 assert_eq!(only_orders_block.errors().len(), 1);
1351 assert!(only_orders_block.warnings().is_empty());
1352 assert!(only_orders_block.infos().is_empty());
1353
1354 let orders_family = report.filter_by_rule_prefix("ORDERS-");
1355 assert_eq!(orders_family.total_issues(), 2);
1356 assert!(orders_family.has_errors());
1357 assert!(!orders_family.has_warnings());
1358
1359 let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1360 assert_eq!(exact.len(), 1);
1361 assert_eq!(exact[0].message, "invoic policy warning");
1362 }
1363}