1use thiserror::Error;
2
3#[derive(Debug)]
7pub struct IoError(pub(crate) std::io::Error);
8
9impl IoError {
10 pub fn inner(&self) -> &std::io::Error {
12 &self.0
13 }
14}
15
16impl PartialEq for IoError {
17 fn eq(&self, other: &Self) -> bool {
24 self.0.kind() == other.0.kind()
25 }
26}
27
28impl std::fmt::Display for IoError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 self.0.fmt(f)
31 }
32}
33
34impl std::error::Error for IoError {
35 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36 self.0.source()
37 }
38}
39
40impl From<std::io::Error> for IoError {
41 fn from(e: std::io::Error) -> Self {
42 Self(e)
43 }
44}
45
46#[derive(Debug, Error, PartialEq)]
53#[non_exhaustive]
54pub enum EdifactError {
55 #[error("unexpected end of input at byte offset {offset}")]
60 UnexpectedEof {
61 offset: usize,
63 },
64
65 #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
70 InvalidDelimiter {
71 byte: u8,
73 offset: usize,
75 },
76
77 #[error("invalid EDIFACT text at byte offset {offset}")]
82 InvalidText {
83 offset: usize,
85 },
86
87 #[error("invalid release sequence at byte offset {offset}: dangling release character")]
92 InvalidReleaseSequence {
93 offset: usize,
95 },
96
97 #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
102 MessageCountMismatch {
103 expected: u32,
105 actual: u32,
107 },
108
109 #[error(
114 "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
115 )]
116 SegmentCountMismatch {
117 expected: u32,
119 actual: u32,
121 message_ref: String,
123 },
124
125 #[error("invalid segment tag {0:?}")]
129 InvalidSegmentTag(String),
130
131 #[error("invalid UNA service string advice")]
139 InvalidUna,
140
141 #[error("missing required element {element_index} in segment {tag}")]
146 MissingRequiredElement {
147 tag: String,
149 element_index: usize,
151 },
152
153 #[error(
157 "missing required component {component_index} in element {element_index} of segment {tag}"
158 )]
159 MissingRequiredComponent {
160 tag: String,
162 element_index: usize,
164 component_index: usize,
166 },
167
168 #[error("serialized output contains invalid UTF-8")]
173 InvalidUtf8,
174
175 #[error(transparent)]
177 Io(#[from] IoError),
178
179 #[error("segment {tag} is not valid for message type {message_type}")]
184 InvalidSegmentForMessage {
185 tag: String,
187 message_type: String,
189 offset: usize,
191 },
192
193 #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
197 InvalidElementCount {
198 tag: String,
200 min: usize,
202 max: usize,
204 actual: usize,
206 offset: usize,
208 },
209
210 #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
214 InvalidComponentCount {
215 tag: String,
217 element_index: usize,
219 expected: u8,
221 actual: u8,
223 offset: usize,
225 },
226
227 #[error(
232 "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
233 )]
234 InvalidCodeValue {
235 tag: String,
237 element_index: usize,
239 value: String,
241 code_list: String,
243 offset: usize,
245 suggestion: Option<&'static str>,
247 },
248
249 #[error("required segment {tag} is missing from message (position {expected_position})")]
253 MissingSegment {
254 tag: String,
256 expected_position: String,
258 },
259
260 #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
264 QualifierMismatch {
265 tag: String,
267 actual: String,
269 expected: String,
271 offset: usize,
273 },
274
275 #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
280 ConditionalRequirementNotMet {
281 tag: String,
283 element_index: usize,
285 condition: String,
287 offset: usize,
289 },
290
291 #[error("validation failed with {error_count} error(s)")]
309 ValidationErrors {
310 error_count: usize,
312 report: Box<ValidationReport>,
314 },
315
316 #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
325 SegmentTooLong {
326 offset: usize,
328 limit: usize,
330 },
331
332 #[error("no handler registered for message type {message_type}")]
338 UnexpectedMessageType {
339 message_type: String,
341 },
342
343 #[error("interchange too large: count {count} exceeds u32::MAX")]
350 InterchangeTooLarge {
351 count: u64,
353 },
354
355 #[error("invalid event sequence: {message}")]
363 InvalidEventSequence {
364 message: &'static str,
366 },
367
368 #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
374 InvalidElementPosition,
375
376 #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
383 IncompatibleReleaseScopes {
384 current: String,
386 incoming: String,
388 },
389
390 #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
396 InvalidFieldValue {
397 tag: String,
399 element_index: usize,
401 value: String,
403 },
404
405 #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
414 UnexpectedDataToken {
415 offset: usize,
417 },
418
419 #[error(
425 "functional group segments (UNG/UNE) at byte offset {offset} are not supported; \
426 strip them before calling validate_envelope"
427 )]
428 FunctionalGroupNotSupported {
429 offset: usize,
431 },
432}
433
434impl From<std::io::Error> for EdifactError {
435 fn from(e: std::io::Error) -> Self {
436 Self::Io(IoError(e))
437 }
438}
439
440impl EdifactError {
441 #[must_use]
443 pub const fn stable_code(&self) -> &'static str {
444 match self {
445 Self::UnexpectedEof { .. } => "E001",
446 Self::InvalidDelimiter { .. } => "E002",
447 Self::InvalidText { .. } => "E003",
448 Self::MessageCountMismatch { .. } => "E004",
449 Self::SegmentCountMismatch { .. } => "E005",
450 Self::InvalidSegmentTag(_) => "E006",
451 Self::InvalidUna => "E007",
452 Self::MissingRequiredElement { .. } => "E008",
453 Self::InvalidUtf8 => "E009",
454 Self::Io(_) => "E010",
455 Self::InvalidSegmentForMessage { .. } => "E011",
456 Self::InvalidElementCount { .. } => "E012",
457 Self::InvalidComponentCount { .. } => "E013",
458 Self::InvalidCodeValue { .. } => "E014",
459 Self::MissingSegment { .. } => "E015",
460 Self::QualifierMismatch { .. } => "E016",
461 Self::ConditionalRequirementNotMet { .. } => "E017",
462 Self::InvalidReleaseSequence { .. } => "E019",
464 Self::SegmentTooLong { .. } => "E020",
465 Self::MissingRequiredComponent { .. } => "E021",
466 Self::UnexpectedMessageType { .. } => "E022",
467 Self::InterchangeTooLarge { .. } => "E023",
468 Self::InvalidEventSequence { .. } => "E024",
469 Self::InvalidElementPosition => "E025",
470 Self::IncompatibleReleaseScopes { .. } => "E026",
471 Self::InvalidFieldValue { .. } => "E027",
472 Self::UnexpectedDataToken { .. } => "E028",
473 Self::FunctionalGroupNotSupported { .. } => "E029",
474 Self::ValidationErrors { .. } => "E030",
475 }
476 }
477
478 #[must_use]
480 pub fn recovery_hint(&self) -> Option<&'static str> {
481 match self {
482 Self::UnexpectedEof { .. } => {
483 Some("Ensure every segment ends with the configured segment terminator")
484 }
485 Self::InvalidDelimiter { .. } => {
486 Some("Check UNA service string advice and delimiter bytes in the payload")
487 }
488 Self::InvalidText { .. } => {
489 Some("Input must be valid UTF-8 text for segment and element values")
490 }
491 Self::InvalidReleaseSequence { .. } => {
492 Some("Release character must escape one following byte; trailing '?' is invalid")
493 }
494 Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
495 Self::InvalidUna => Some(
496 "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
497 ),
498 Self::MissingRequiredElement { .. } => {
499 Some("Provide all mandatory elements for the segment per directory rules")
500 }
501 Self::MissingRequiredComponent { .. } => Some(
502 "Provide all mandatory components for the composite element per directory rules",
503 ),
504 Self::InvalidSegmentForMessage { .. } => {
505 Some("Remove unsupported segment or switch to the correct message type")
506 }
507 Self::InvalidElementCount { .. } => {
508 Some("Adjust the segment element count to the allowed min/max range")
509 }
510 Self::InvalidComponentCount { .. } => {
511 Some("Fix composite element arity to match the expected component count")
512 }
513 Self::InvalidCodeValue { .. } => {
514 Some("Use a value from the referenced code list for this element")
515 }
516 Self::MissingSegment { .. } => {
517 Some("Insert the required segment at the expected position")
518 }
519 Self::QualifierMismatch { .. } => {
520 Some("Set the segment qualifier to the expected value")
521 }
522 Self::ConditionalRequirementNotMet { .. } => {
523 Some("When the condition is met, include the conditionally required element")
524 }
525 Self::SegmentTooLong { limit, .. } => {
526 let _ = limit; Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
528 }
529 Self::InvalidEventSequence { .. } => {
530 Some("Emit StartSegment before Element, and Element before ComponentElement")
531 }
532 Self::InvalidElementPosition => Some(
533 "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
534 ),
535 Self::IncompatibleReleaseScopes { .. } => Some(
536 "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
537 ),
538 Self::InvalidFieldValue { .. } => Some(
539 "Correct the field value to match the expected format or range for this element",
540 ),
541 Self::UnexpectedDataToken { .. } => Some(
542 "A data element appeared before any segment tag; check for partial writes or encoding corruption",
543 ),
544 Self::FunctionalGroupNotSupported { .. } => Some(
545 "Strip UNG/UNE segments before calling validate_envelope, or process the interchange as raw segments",
546 ),
547 Self::ValidationErrors { .. }
548 | Self::MessageCountMismatch { .. }
549 | Self::SegmentCountMismatch { .. }
550 | Self::UnexpectedMessageType { .. }
551 | Self::InterchangeTooLarge { .. }
552 | Self::InvalidUtf8
553 | Self::Io(_) => None,
554 }
555 }
556}
557
558#[cfg(feature = "diagnostics")]
559#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
560impl miette::Diagnostic for EdifactError {
561 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
562 Some(Box::new(self.stable_code()))
563 }
564
565 fn severity(&self) -> Option<miette::Severity> {
566 match self {
567 Self::InvalidCodeValue { .. }
568 | Self::InvalidComponentCount { .. }
569 | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
570 _ => Some(miette::Severity::Error),
571 }
572 }
573
574 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
575 match self {
576 Self::InvalidUna => Some(Box::new(
578 "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
579 )),
580 Self::InvalidUtf8 => Some(Box::new(
581 "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
582 )),
583 Self::UnexpectedEof { offset } => Some(Box::new(format!(
585 "Check that all segments are terminated with the segment terminator (usually '). \
586 Reached end at offset {offset}",
587 ))),
588 Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
589 "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
590 Check UNA configuration",
591 ))),
592 Self::InvalidText { offset } => Some(Box::new(format!(
593 "The byte sequence at offset {offset} contains invalid UTF-8. \
594 Ensure input is valid UTF-8",
595 ))),
596 Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
597 "Release character at offset {offset} is dangling. \
598 Ensure '?' is followed by an escaped byte",
599 ))),
600 Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
601 "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
602 Check the UNZ message count",
603 ))),
604 Self::SegmentCountMismatch {
605 expected,
606 actual,
607 message_ref,
608 } => Some(Box::new(format!(
609 "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
610 Check the UNT segment count",
611 ))),
612 Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
613 "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
614 ))),
615 Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
616 "Segment {tag} requires element at index {element_index}",
617 ))),
618 Self::MissingRequiredComponent {
619 tag,
620 element_index,
621 component_index,
622 } => Some(Box::new(format!(
623 "Segment {tag} element {element_index} requires component at index {component_index}",
624 ))),
625 Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
626 Self::InvalidSegmentForMessage {
627 tag, message_type, ..
628 } => Some(Box::new(format!(
629 "Segment {tag} should not appear in a {message_type} message. \
630 Check the directory definition",
631 ))),
632 Self::InvalidElementCount {
633 tag,
634 min,
635 max,
636 actual,
637 ..
638 } => Some(Box::new(format!(
639 "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
640 Check segment structure",
641 ))),
642 Self::InvalidComponentCount {
643 tag,
644 element_index,
645 expected,
646 actual,
647 ..
648 } => Some(Box::new(format!(
649 "In segment {tag}, element {element_index} should have {expected} components \
650 but has {actual}. Check element structure",
651 ))),
652 Self::InvalidCodeValue {
653 tag,
654 element_index,
655 value,
656 code_list,
657 ..
658 } => Some(Box::new(format!(
659 "Value '{value}' in segment {tag} element {element_index} is not in the \
660 {code_list} code list. Check the directory for valid codes",
661 ))),
662 Self::MissingSegment {
663 tag,
664 expected_position,
665 } => Some(Box::new(format!(
666 "Segment {tag} is required at position {expected_position} but is missing. \
667 Add this segment to the message",
668 ))),
669 Self::QualifierMismatch {
670 tag,
671 actual,
672 expected,
673 ..
674 } => Some(Box::new(format!(
675 "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
676 Check the segment's first component",
677 ))),
678 Self::ConditionalRequirementNotMet {
679 tag,
680 element_index,
681 condition,
682 ..
683 } => Some(Box::new(format!(
684 "In segment {tag}, element {element_index} is conditionally required when: \
685 {condition}. Check if the condition is met",
686 ))),
687 Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
688 "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
689 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
690 or verify the input for a missing segment terminator",
691 ))),
692 Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
693 "No handler was registered for message type '{message_type}'. \
694 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
695 ))),
696 Self::InterchangeTooLarge { count } => Some(Box::new(format!(
697 "Interchange contains {count} items which exceeds the u32::MAX limit. \
698 This is an extremely unusual input; verify the message is not corrupted.",
699 ))),
700 Self::InvalidEventSequence { message } => Some(Box::new(format!(
701 "Event sequence violation: {message}. \
702 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
703 ))),
704 Self::InvalidElementPosition => Some(Box::new(
705 "Element positions must be >= 1 (one-based). \
706 Ensure no OwnedElementRef is constructed with position == 0",
707 )),
708 Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
709 "Release scope {current:?} and {incoming:?} are incompatible. \
710 Only compose ProfileRulePack values that share the same release scope, \
711 or where at most one carries a release scope",
712 ))),
713 Self::InvalidFieldValue {
714 tag,
715 element_index,
716 value,
717 } => Some(Box::new(format!(
718 "Segment {tag} element {element_index} has invalid value '{value}'. \
719 Check the expected format or range for this field",
720 ))),
721 Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
722 "Data element at offset {offset} appeared before any segment tag. \
723 Check for partial writes or encoding corruption",
724 ))),
725 Self::FunctionalGroupNotSupported { offset } => Some(Box::new(format!(
726 "Functional group segment (UNG/UNE) found at offset {offset}. \
727 Strip UNG/UNE wrappers before calling validate_envelope",
728 ))),
729 Self::ValidationErrors { error_count, .. } => Some(Box::new(format!(
730 "Validation found {error_count} error(s). Inspect the ValidationReport for details",
731 ))),
732 }
733 }
734}
735
736#[non_exhaustive]
743#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
744pub enum ValidationSeverity {
745 Critical,
747 Error,
749 Warning,
751 Info,
753}
754
755impl std::fmt::Display for ValidationSeverity {
756 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757 match self {
758 Self::Critical => f.write_str("critical"),
759 Self::Error => f.write_str("error"),
760 Self::Warning => f.write_str("warning"),
761 Self::Info => f.write_str("info"),
762 }
763 }
764}
765
766#[derive(Debug, Clone, PartialEq)]
768pub struct ValidationIssue {
769 pub error_code: Option<&'static str>,
771 pub severity: ValidationSeverity,
773 pub message: String,
775 pub offset: Option<usize>,
777 pub segment_tag: Option<String>,
779 pub rule_id: Option<String>,
781 pub element_index: Option<u8>,
786 pub component_index: Option<u8>,
791 pub segment_occurrence: Option<u16>,
797 pub message_ref: Option<String>,
804 pub suggestion: Option<String>,
806}
807
808impl ValidationIssue {
809 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
811 Self {
812 error_code: None,
813 severity,
814 message: message.into(),
815 offset: None,
816 segment_tag: None,
817 rule_id: None,
818 element_index: None,
819 component_index: None,
820 segment_occurrence: None,
821 message_ref: None,
822 suggestion: None,
823 }
824 }
825
826 pub fn with_error_code(mut self, code: &'static str) -> Self {
828 self.error_code = Some(code);
829 self
830 }
831
832 pub fn with_offset(mut self, offset: usize) -> Self {
834 self.offset = Some(offset);
835 self
836 }
837
838 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
840 self.segment_tag = Some(tag.into());
841 self
842 }
843
844 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
846 self.rule_id = Some(rule_id.into());
847 self
848 }
849
850 pub fn with_element_index(mut self, element_index: u8) -> Self {
852 self.element_index = Some(element_index);
853 self
854 }
855
856 pub fn with_component_index(mut self, component_index: u8) -> Self {
858 self.component_index = Some(component_index);
859 self
860 }
861
862 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
864 self.suggestion = Some(suggestion.into());
865 self
866 }
867
868 pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
873 self.segment_occurrence = Some(occurrence);
874 self
875 }
876
877 pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
882 self.message_ref = Some(message_ref.into());
883 self
884 }
885
886 #[must_use]
888 pub fn severity_label(&self) -> &'static str {
889 match self.severity {
890 ValidationSeverity::Critical => "CRITICAL",
891 ValidationSeverity::Error => "ERROR",
892 ValidationSeverity::Warning => "WARNING",
893 ValidationSeverity::Info => "INFO",
894 }
895 }
896
897 #[must_use]
901 #[inline]
902 pub fn error_code(&self) -> Option<&'static str> {
903 self.error_code
904 }
905
906 #[must_use]
908 #[inline]
909 pub fn offset(&self) -> Option<usize> {
910 self.offset
911 }
912
913 #[must_use]
915 #[inline]
916 pub fn segment_tag(&self) -> Option<&str> {
917 self.segment_tag.as_deref()
918 }
919
920 #[must_use]
922 #[inline]
923 pub fn rule_id(&self) -> Option<&str> {
924 self.rule_id.as_deref()
925 }
926
927 #[must_use]
929 #[inline]
930 pub fn element_index(&self) -> Option<u8> {
931 self.element_index
932 }
933
934 #[must_use]
936 #[inline]
937 pub fn component_index(&self) -> Option<u8> {
938 self.component_index
939 }
940
941 #[must_use]
943 #[inline]
944 pub fn segment_occurrence(&self) -> Option<u16> {
945 self.segment_occurrence
946 }
947
948 #[must_use]
950 #[inline]
951 pub fn message_ref(&self) -> Option<&str> {
952 self.message_ref.as_deref()
953 }
954
955 #[must_use]
957 #[inline]
958 pub fn suggestion(&self) -> Option<&str> {
959 self.suggestion.as_deref()
960 }
961}
962
963impl std::fmt::Display for ValidationIssue {
964 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
965 write!(f, "[{}] {}", self.severity_label(), self.message)
966 }
967}
968
969impl std::error::Error for ValidationIssue {}
970
971#[derive(Debug, Clone, Default, PartialEq)]
975pub struct ValidationReport {
976 pub(crate) errors: Vec<ValidationIssue>,
978 pub(crate) warnings: Vec<ValidationIssue>,
980 pub(crate) infos: Vec<ValidationIssue>,
982}
983
984impl ValidationReport {
985 pub fn errors(&self) -> &[ValidationIssue] {
987 &self.errors
988 }
989
990 pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
992 &mut self.errors
993 }
994
995 pub fn warnings(&self) -> &[ValidationIssue] {
997 &self.warnings
998 }
999
1000 pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
1002 &mut self.warnings
1003 }
1004
1005 pub fn infos(&self) -> &[ValidationIssue] {
1007 &self.infos
1008 }
1009
1010 pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
1012 &mut self.infos
1013 }
1014 pub fn add_error(&mut self, issue: ValidationIssue) {
1016 self.errors.push(issue);
1017 }
1018
1019 pub fn add_warning(&mut self, issue: ValidationIssue) {
1021 self.warnings.push(issue);
1022 }
1023
1024 pub fn add_info(&mut self, issue: ValidationIssue) {
1026 self.infos.push(issue);
1027 }
1028
1029 pub fn has_errors(&self) -> bool {
1031 !self.errors().is_empty()
1032 }
1033
1034 pub fn has_warnings(&self) -> bool {
1036 !self.warnings().is_empty()
1037 }
1038
1039 pub fn total_issues(&self) -> usize {
1041 self.errors().len() + self.warnings().len() + self.infos().len()
1042 }
1043
1044 pub fn is_valid(&self) -> bool {
1046 self.errors().is_empty()
1047 }
1048
1049 pub fn result(self) -> Result<Self, Self> {
1055 if self.is_valid() { Ok(self) } else { Err(self) }
1056 }
1057
1058 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
1060 self.errors()
1061 .iter()
1062 .chain(self.warnings().iter())
1063 .chain(self.infos().iter())
1064 }
1065
1066 pub fn has_any_issues(&self) -> bool {
1068 !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
1069 }
1070
1071 pub fn merge(&mut self, mut other: ValidationReport) {
1076 self.errors.append(&mut other.errors);
1077 self.warnings.append(&mut other.warnings);
1078 self.infos.append(&mut other.infos);
1079 }
1080
1081 pub fn issues_for_rule_id<'a>(
1086 &'a self,
1087 rule_id: &'a str,
1088 ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
1089 self.iter_issues()
1090 .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
1091 }
1092
1093 fn filter_report<F>(&self, pred: F) -> Self
1095 where
1096 F: Fn(&ValidationIssue) -> bool,
1097 {
1098 Self {
1099 errors: self.errors().iter().filter(|i| pred(i)).cloned().collect(),
1100 warnings: self
1101 .warnings()
1102 .iter()
1103 .filter(|i| pred(i))
1104 .cloned()
1105 .collect(),
1106 infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
1107 }
1108 }
1109
1110 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
1112 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
1113 }
1114
1115 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
1117 self.filter_report(|issue| {
1118 issue
1119 .rule_id
1120 .as_deref()
1121 .is_some_and(|id| id.starts_with(prefix))
1122 })
1123 }
1124
1125 pub fn for_segment(&self, segment_tag: &str) -> Self {
1149 self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
1150 }
1151
1152 pub fn render_deterministic(&self) -> String {
1154 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
1155 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
1156 refs.sort_by(|left, right| {
1157 left.offset
1158 .unwrap_or(usize::MAX)
1159 .cmp(&right.offset.unwrap_or(usize::MAX))
1160 .then_with(|| {
1161 left.segment_tag
1162 .as_deref()
1163 .unwrap_or("")
1164 .cmp(right.segment_tag.as_deref().unwrap_or(""))
1165 })
1166 .then_with(|| {
1167 left.rule_id
1168 .as_deref()
1169 .unwrap_or("")
1170 .cmp(right.rule_id.as_deref().unwrap_or(""))
1171 })
1172 .then_with(|| {
1173 left.element_index
1174 .unwrap_or(u8::MAX)
1175 .cmp(&right.element_index.unwrap_or(u8::MAX))
1176 })
1177 .then_with(|| {
1178 left.component_index
1179 .unwrap_or(u8::MAX)
1180 .cmp(&right.component_index.unwrap_or(u8::MAX))
1181 })
1182 .then_with(|| {
1183 left.error_code
1184 .unwrap_or("")
1185 .cmp(right.error_code.unwrap_or(""))
1186 })
1187 .then_with(|| left.message.cmp(&right.message))
1188 });
1189 refs
1190 }
1191
1192 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
1193 use std::fmt::Write as _;
1194 out.push_str(" - ");
1195 out.push_str(&issue.message);
1196 if let Some(code) = issue.error_code {
1197 out.push_str(" [");
1198 out.push_str(code);
1199 out.push(']');
1200 }
1201 if let Some(seg) = &issue.segment_tag {
1202 out.push_str(" [segment=");
1203 out.push_str(seg);
1204 out.push(']');
1205 }
1206 if let Some(rule_id) = &issue.rule_id {
1207 out.push_str(" [rule=");
1208 out.push_str(rule_id);
1209 out.push(']');
1210 }
1211 if let Some(element_index) = issue.element_index {
1212 write!(out, " [element={element_index}]").ok();
1213 }
1214 if let Some(component_index) = issue.component_index {
1215 write!(out, " [component={component_index}]").ok();
1216 }
1217 if let Some(offset) = issue.offset {
1218 write!(out, " [offset={offset}]").ok();
1219 }
1220 if let Some(suggestion) = &issue.suggestion {
1221 out.push_str(" [hint=");
1222 out.push_str(suggestion);
1223 out.push(']');
1224 }
1225 }
1226
1227 use std::fmt::Write as _;
1228 let mut out = String::from("Validation Report:");
1229 let errors = sorted_refs(self.errors());
1230 let warnings = sorted_refs(self.warnings());
1231 let infos = sorted_refs(self.infos());
1232
1233 if !errors.is_empty() {
1234 write!(out, "\n Errors ({})", errors.len()).ok();
1235 for issue in &errors {
1236 out.push('\n');
1237 render_issue_line(&mut out, issue);
1238 }
1239 }
1240 if !warnings.is_empty() {
1241 write!(out, "\n Warnings ({})", warnings.len()).ok();
1242 for issue in &warnings {
1243 out.push('\n');
1244 render_issue_line(&mut out, issue);
1245 }
1246 }
1247 if !infos.is_empty() {
1248 write!(out, "\n Info ({})", infos.len()).ok();
1249 for issue in &infos {
1250 out.push('\n');
1251 render_issue_line(&mut out, issue);
1252 }
1253 }
1254
1255 out
1256 }
1257}
1258
1259#[cfg(feature = "diagnostics")]
1260impl miette::Diagnostic for ValidationReport {
1261 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1262 Some(Box::new("VALIDATION"))
1263 }
1264
1265 fn severity(&self) -> Option<miette::Severity> {
1266 if self.has_errors() {
1267 Some(miette::Severity::Error)
1268 } else if self.has_warnings() {
1269 Some(miette::Severity::Warning)
1270 } else {
1271 Some(miette::Severity::Advice)
1272 }
1273 }
1274
1275 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1276 let msg = format!(
1277 "Validation found {} error(s), {} warning(s), {} info(s)",
1278 self.errors().len(),
1279 self.warnings().len(),
1280 self.infos().len()
1281 );
1282 Some(Box::new(msg))
1283 }
1284}
1285
1286impl std::fmt::Display for ValidationReport {
1287 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1288 write!(f, "{}", self.render_deterministic())
1289 }
1290}
1291
1292impl std::error::Error for ValidationReport {}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297
1298 #[test]
1299 fn validation_report_collects_errors() {
1300 let mut report = ValidationReport::default();
1301 report.add_error(
1302 ValidationIssue::new(ValidationSeverity::Error, "Test error")
1303 .with_segment("BGM")
1304 .with_offset(42),
1305 );
1306 report.add_warning(ValidationIssue::new(
1307 ValidationSeverity::Warning,
1308 "Test warning",
1309 ));
1310
1311 assert!(report.has_errors());
1312 assert!(report.has_warnings());
1313 assert_eq!(report.total_issues(), 2);
1314 assert!(!report.is_valid());
1315 }
1316
1317 #[test]
1318 fn validation_report_result_conversion() {
1319 let mut report = ValidationReport::default();
1320 report.add_error(ValidationIssue::new(
1321 ValidationSeverity::Error,
1322 "Critical issue",
1323 ));
1324
1325 let result = report.result();
1326 assert!(result.is_err());
1327 }
1328
1329 #[test]
1330 fn validation_report_passes_when_no_errors() {
1331 let mut report = ValidationReport::default();
1332 report.add_warning(ValidationIssue::new(
1333 ValidationSeverity::Warning,
1334 "Just a warning",
1335 ));
1336
1337 assert!(report.is_valid());
1338 assert!(report.result().is_ok());
1339 }
1340
1341 #[test]
1342 fn validation_issue_builder() {
1343 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1344 .with_error_code("E013")
1345 .with_offset(100)
1346 .with_segment("NAD")
1347 .with_rule_id("DEMO-P001")
1348 .with_element_index(1)
1349 .with_component_index(2)
1350 .with_suggestion("Check element count");
1351
1352 assert_eq!(issue.error_code, Some("E013"));
1353 assert_eq!(issue.message, "test message");
1354 assert_eq!(issue.offset, Some(100));
1355 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1356 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1357 assert_eq!(issue.element_index, Some(1));
1358 assert_eq!(issue.component_index, Some(2));
1359 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1360 }
1361
1362 #[test]
1363 fn validation_report_display() {
1364 let mut report = ValidationReport::default();
1365 report.add_error(
1366 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1367 .with_error_code("E011")
1368 .with_offset(8),
1369 );
1370 report.add_warning(ValidationIssue::new(
1371 ValidationSeverity::Warning,
1372 "Warning 1",
1373 ));
1374 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1375
1376 let display_str = format!("{}", report);
1377 assert!(display_str.contains("Errors (1)"));
1378 assert!(display_str.contains("Warnings (1)"));
1379 assert!(display_str.contains("Info (1)"));
1380 assert!(display_str.contains("[E011]"));
1381 }
1382
1383 #[test]
1384 fn validation_report_render_is_deterministic() {
1385 let mut report = ValidationReport::default();
1386 report.add_error(
1387 ValidationIssue::new(ValidationSeverity::Error, "later")
1388 .with_segment("BGM")
1389 .with_offset(20),
1390 );
1391 report.add_error(
1392 ValidationIssue::new(ValidationSeverity::Error, "earlier")
1393 .with_segment("UNH")
1394 .with_offset(1),
1395 );
1396
1397 let rendered = report.render_deterministic();
1398 let first = rendered.find("earlier").expect("missing first issue");
1399 let second = rendered.find("later").expect("missing second issue");
1400 assert!(first < second, "expected deterministic sort by offset");
1401 }
1402
1403 #[test]
1404 fn recovery_hint_exists_for_common_malformed_cases() {
1405 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1406 assert!(err.recovery_hint().is_some());
1407
1408 let err = EdifactError::InvalidCodeValue {
1409 tag: "BGM".to_owned(),
1410 element_index: 0,
1411 value: "X".to_owned(),
1412 code_list: "1001".to_owned(),
1413 offset: 0,
1414 suggestion: None,
1415 };
1416 assert!(err.recovery_hint().is_some());
1417 }
1418
1419 #[test]
1420 fn validation_report_can_filter_by_rule_id() {
1421 let mut report = ValidationReport::default();
1422 report.add_error(
1423 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1424 .with_rule_id("ORDERS-P001"),
1425 );
1426 report.add_warning(
1427 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1428 .with_rule_id("INVOIC-P001"),
1429 );
1430 report.add_info(
1431 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1432 .with_rule_id("ORDERS-P002"),
1433 );
1434
1435 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1436 assert_eq!(only_orders_block.errors().len(), 1);
1437 assert!(only_orders_block.warnings().is_empty());
1438 assert!(only_orders_block.infos().is_empty());
1439
1440 let orders_family = report.filter_by_rule_prefix("ORDERS-");
1441 assert_eq!(orders_family.total_issues(), 2);
1442 assert!(orders_family.has_errors());
1443 assert!(!orders_family.has_warnings());
1444
1445 let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1446 assert_eq!(exact.len(), 1);
1447 assert_eq!(exact[0].message, "invoic policy warning");
1448 }
1449}