1use std::sync::Arc;
2use thiserror::Error;
3
4#[derive(Debug)]
8pub struct IoError(pub(crate) std::io::Error);
9
10impl IoError {
11 pub fn inner(&self) -> &std::io::Error {
13 &self.0
14 }
15}
16
17impl PartialEq for IoError {
18 fn eq(&self, other: &Self) -> bool {
25 self.0.kind() == other.0.kind()
26 }
27}
28
29impl std::fmt::Display for IoError {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 self.0.fmt(f)
32 }
33}
34
35impl std::error::Error for IoError {
36 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
37 self.0.source()
38 }
39}
40
41impl From<std::io::Error> for IoError {
42 fn from(e: std::io::Error) -> Self {
43 Self(e)
44 }
45}
46
47#[derive(Debug, Error, PartialEq)]
54#[non_exhaustive]
55pub enum EdifactError {
56 #[error("unexpected end of input at byte offset {offset}")]
61 UnexpectedEof {
62 offset: usize,
64 },
65
66 #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
71 InvalidDelimiter {
72 byte: u8,
74 offset: usize,
76 },
77
78 #[error("invalid EDIFACT text at byte offset {offset}")]
83 InvalidText {
84 offset: usize,
86 },
87
88 #[error("invalid release sequence at byte offset {offset}: dangling release character")]
93 InvalidReleaseSequence {
94 offset: usize,
96 },
97
98 #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
103 MessageCountMismatch {
104 expected: u32,
106 actual: u32,
108 },
109
110 #[error(
115 "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
116 )]
117 SegmentCountMismatch {
118 expected: u32,
120 actual: u32,
122 message_ref: String,
124 },
125
126 #[error("invalid segment tag {0:?}")]
130 InvalidSegmentTag(String),
131
132 #[error("invalid UNA service string advice")]
140 InvalidUna,
141
142 #[error("missing required element {element_index} in segment {tag}")]
147 MissingRequiredElement {
148 tag: String,
150 element_index: usize,
152 },
153
154 #[error(
158 "missing required component {component_index} in element {element_index} of segment {tag}"
159 )]
160 MissingRequiredComponent {
161 tag: String,
163 element_index: usize,
165 component_index: usize,
167 },
168
169 #[error("serialized output contains invalid UTF-8")]
174 InvalidUtf8,
175
176 #[error(transparent)]
178 Io(#[from] IoError),
179
180 #[error("segment {tag} is not valid for message type {message_type}")]
185 InvalidSegmentForMessage {
186 tag: String,
188 message_type: String,
190 offset: usize,
192 },
193
194 #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
198 InvalidElementCount {
199 tag: String,
201 min: usize,
203 max: usize,
205 actual: usize,
207 offset: usize,
209 },
210
211 #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
215 InvalidComponentCount {
216 tag: String,
218 element_index: usize,
220 expected: u8,
222 actual: u8,
224 offset: usize,
226 },
227
228 #[error(
233 "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
234 )]
235 InvalidCodeValue {
236 tag: String,
238 element_index: usize,
240 value: String,
242 code_list: String,
244 offset: usize,
246 suggestion: Option<&'static str>,
248 },
249
250 #[error("required segment {tag} is missing from message (position {expected_position})")]
254 MissingSegment {
255 tag: String,
257 expected_position: String,
259 },
260
261 #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
265 QualifierMismatch {
266 tag: String,
268 actual: String,
270 expected: String,
272 offset: usize,
274 },
275
276 #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
281 ConditionalRequirementNotMet {
282 tag: String,
284 element_index: usize,
286 condition: String,
288 offset: usize,
290 },
291
292 #[error("validation failed with {error_count} error(s)")]
310 ValidationErrors {
311 error_count: usize,
313 report: Box<ValidationReport>,
315 },
316
317 #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
326 SegmentTooLong {
327 offset: usize,
329 limit: usize,
331 },
332
333 #[error("no handler registered for message type {message_type}")]
339 UnexpectedMessageType {
340 message_type: String,
342 },
343
344 #[error("interchange too large: count {count} exceeds u32::MAX")]
351 InterchangeTooLarge {
352 count: u64,
354 },
355
356 #[error("invalid event sequence: {message}")]
364 InvalidEventSequence {
365 message: &'static str,
367 },
368
369 #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
375 InvalidElementPosition,
376
377 #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
383 #[non_exhaustive]
384 IncompatibleReleaseScopes {
385 current: String,
387 incoming: String,
389 },
390
391 #[error("segment {tag} element {element_index}: invalid field value {value:?}")]
397 InvalidFieldValue {
398 tag: String,
400 element_index: usize,
402 value: String,
404 },
405
406 #[error("unexpected data token at byte offset {offset}: data element before segment tag")]
415 UnexpectedDataToken {
416 offset: usize,
418 },
419
420 #[error(
426 "functional group segments (UNG/UNE) at byte offset {offset} are not supported; \
427 strip them before calling validate_envelope"
428 )]
429 FunctionalGroupNotSupported {
430 offset: usize,
432 },
433}
434
435impl From<std::io::Error> for EdifactError {
436 fn from(e: std::io::Error) -> Self {
437 Self::Io(IoError(e))
438 }
439}
440
441impl EdifactError {
442 #[must_use]
444 pub const fn stable_code(&self) -> &'static str {
445 match self {
446 Self::UnexpectedEof { .. } => "E001",
447 Self::InvalidDelimiter { .. } => "E002",
448 Self::InvalidText { .. } => "E003",
449 Self::MessageCountMismatch { .. } => "E004",
450 Self::SegmentCountMismatch { .. } => "E005",
451 Self::InvalidSegmentTag(_) => "E006",
452 Self::InvalidUna => "E007",
453 Self::MissingRequiredElement { .. } => "E008",
454 Self::InvalidUtf8 => "E009",
455 Self::Io(_) => "E010",
456 Self::InvalidSegmentForMessage { .. } => "E011",
457 Self::InvalidElementCount { .. } => "E012",
458 Self::InvalidComponentCount { .. } => "E013",
459 Self::InvalidCodeValue { .. } => "E014",
460 Self::MissingSegment { .. } => "E015",
461 Self::QualifierMismatch { .. } => "E016",
462 Self::ConditionalRequirementNotMet { .. } => "E017",
463 Self::InvalidReleaseSequence { .. } => "E019",
465 Self::SegmentTooLong { .. } => "E020",
466 Self::MissingRequiredComponent { .. } => "E021",
467 Self::UnexpectedMessageType { .. } => "E022",
468 Self::InterchangeTooLarge { .. } => "E023",
469 Self::InvalidEventSequence { .. } => "E024",
470 Self::InvalidElementPosition => "E025",
471 Self::IncompatibleReleaseScopes { .. } => "E026",
472 Self::InvalidFieldValue { .. } => "E027",
473 Self::UnexpectedDataToken { .. } => "E028",
474 Self::FunctionalGroupNotSupported { .. } => "E029",
475 Self::ValidationErrors { .. } => "E030",
476 }
477 }
478
479 #[must_use]
481 pub fn recovery_hint(&self) -> Option<&'static str> {
482 match self {
483 Self::UnexpectedEof { .. } => {
484 Some("Ensure every segment ends with the configured segment terminator")
485 }
486 Self::InvalidDelimiter { .. } => {
487 Some("Check UNA service string advice and delimiter bytes in the payload")
488 }
489 Self::InvalidText { .. } => {
490 Some("Input must be valid UTF-8 text for segment and element values")
491 }
492 Self::InvalidReleaseSequence { .. } => {
493 Some("Release character must escape one following byte; trailing '?' is invalid")
494 }
495 Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
496 Self::InvalidUna => Some(
497 "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
498 ),
499 Self::MissingRequiredElement { .. } => {
500 Some("Provide all mandatory elements for the segment per directory rules")
501 }
502 Self::MissingRequiredComponent { .. } => Some(
503 "Provide all mandatory components for the composite element per directory rules",
504 ),
505 Self::InvalidSegmentForMessage { .. } => {
506 Some("Remove unsupported segment or switch to the correct message type")
507 }
508 Self::InvalidElementCount { .. } => {
509 Some("Adjust the segment element count to the allowed min/max range")
510 }
511 Self::InvalidComponentCount { .. } => {
512 Some("Fix composite element arity to match the expected component count")
513 }
514 Self::InvalidCodeValue { .. } => {
515 Some("Use a value from the referenced code list for this element")
516 }
517 Self::MissingSegment { .. } => {
518 Some("Insert the required segment at the expected position")
519 }
520 Self::QualifierMismatch { .. } => {
521 Some("Set the segment qualifier to the expected value")
522 }
523 Self::ConditionalRequirementNotMet { .. } => {
524 Some("When the condition is met, include the conditionally required element")
525 }
526 Self::SegmentTooLong { limit, .. } => {
527 let _ = limit; Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
529 }
530 Self::InvalidEventSequence { .. } => {
531 Some("Emit StartSegment before Element, and Element before ComponentElement")
532 }
533 Self::InvalidElementPosition => Some(
534 "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
535 ),
536 Self::IncompatibleReleaseScopes { .. } => Some(
537 "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
538 ),
539 Self::InvalidFieldValue { .. } => Some(
540 "Correct the field value to match the expected format or range for this element",
541 ),
542 Self::UnexpectedDataToken { .. } => Some(
543 "A data element appeared before any segment tag; check for partial writes or encoding corruption",
544 ),
545 Self::FunctionalGroupNotSupported { .. } => Some(
546 "Strip UNG/UNE segments before calling validate_envelope, or process the interchange as raw segments",
547 ),
548 Self::ValidationErrors { .. }
549 | Self::MessageCountMismatch { .. }
550 | Self::SegmentCountMismatch { .. }
551 | Self::UnexpectedMessageType { .. }
552 | Self::InterchangeTooLarge { .. }
553 | Self::InvalidUtf8
554 | Self::Io(_) => None,
555 }
556 }
557}
558
559#[cfg(feature = "diagnostics")]
560#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
561impl miette::Diagnostic for EdifactError {
562 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
563 Some(Box::new(self.stable_code()))
564 }
565
566 fn severity(&self) -> Option<miette::Severity> {
567 match self {
568 Self::InvalidCodeValue { .. }
569 | Self::InvalidComponentCount { .. }
570 | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
571 _ => Some(miette::Severity::Error),
572 }
573 }
574
575 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
576 match self {
577 Self::InvalidUna => Some(Box::new(
579 "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
580 )),
581 Self::InvalidUtf8 => Some(Box::new(
582 "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
583 )),
584 Self::UnexpectedEof { offset } => Some(Box::new(format!(
586 "Check that all segments are terminated with the segment terminator (usually '). \
587 Reached end at offset {offset}",
588 ))),
589 Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
590 "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
591 Check UNA configuration",
592 ))),
593 Self::InvalidText { offset } => Some(Box::new(format!(
594 "The byte sequence at offset {offset} contains invalid UTF-8. \
595 Ensure input is valid UTF-8",
596 ))),
597 Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
598 "Release character at offset {offset} is dangling. \
599 Ensure '?' is followed by an escaped byte",
600 ))),
601 Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
602 "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
603 Check the UNZ message count",
604 ))),
605 Self::SegmentCountMismatch {
606 expected,
607 actual,
608 message_ref,
609 } => Some(Box::new(format!(
610 "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
611 Check the UNT segment count",
612 ))),
613 Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
614 "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
615 ))),
616 Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
617 "Segment {tag} requires element at index {element_index}",
618 ))),
619 Self::MissingRequiredComponent {
620 tag,
621 element_index,
622 component_index,
623 } => Some(Box::new(format!(
624 "Segment {tag} element {element_index} requires component at index {component_index}",
625 ))),
626 Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
627 Self::InvalidSegmentForMessage {
628 tag, message_type, ..
629 } => Some(Box::new(format!(
630 "Segment {tag} should not appear in a {message_type} message. \
631 Check the directory definition",
632 ))),
633 Self::InvalidElementCount {
634 tag,
635 min,
636 max,
637 actual,
638 ..
639 } => Some(Box::new(format!(
640 "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
641 Check segment structure",
642 ))),
643 Self::InvalidComponentCount {
644 tag,
645 element_index,
646 expected,
647 actual,
648 ..
649 } => Some(Box::new(format!(
650 "In segment {tag}, element {element_index} should have {expected} components \
651 but has {actual}. Check element structure",
652 ))),
653 Self::InvalidCodeValue {
654 tag,
655 element_index,
656 value,
657 code_list,
658 ..
659 } => Some(Box::new(format!(
660 "Value '{value}' in segment {tag} element {element_index} is not in the \
661 {code_list} code list. Check the directory for valid codes",
662 ))),
663 Self::MissingSegment {
664 tag,
665 expected_position,
666 } => Some(Box::new(format!(
667 "Segment {tag} is required at position {expected_position} but is missing. \
668 Add this segment to the message",
669 ))),
670 Self::QualifierMismatch {
671 tag,
672 actual,
673 expected,
674 ..
675 } => Some(Box::new(format!(
676 "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
677 Check the segment's first component",
678 ))),
679 Self::ConditionalRequirementNotMet {
680 tag,
681 element_index,
682 condition,
683 ..
684 } => Some(Box::new(format!(
685 "In segment {tag}, element {element_index} is conditionally required when: \
686 {condition}. Check if the condition is met",
687 ))),
688 Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
689 "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
690 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
691 or verify the input for a missing segment terminator",
692 ))),
693 Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
694 "No handler was registered for message type '{message_type}'. \
695 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
696 ))),
697 Self::InterchangeTooLarge { count } => Some(Box::new(format!(
698 "Interchange contains {count} items which exceeds the u32::MAX limit. \
699 This is an extremely unusual input; verify the message is not corrupted.",
700 ))),
701 Self::InvalidEventSequence { message } => Some(Box::new(format!(
702 "Event sequence violation: {message}. \
703 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
704 ))),
705 Self::InvalidElementPosition => Some(Box::new(
706 "Element positions must be >= 1 (one-based). \
707 Ensure no OwnedElementRef is constructed with position == 0",
708 )),
709 Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
710 "Release scope {current:?} and {incoming:?} are incompatible. \
711 Only compose ProfileRulePack values that share the same release scope, \
712 or where at most one carries a release scope",
713 ))),
714 Self::InvalidFieldValue {
715 tag,
716 element_index,
717 value,
718 } => Some(Box::new(format!(
719 "Segment {tag} element {element_index} has invalid value '{value}'. \
720 Check the expected format or range for this field",
721 ))),
722 Self::UnexpectedDataToken { offset } => Some(Box::new(format!(
723 "Data element at offset {offset} appeared before any segment tag. \
724 Check for partial writes or encoding corruption",
725 ))),
726 Self::FunctionalGroupNotSupported { offset } => Some(Box::new(format!(
727 "Functional group segment (UNG/UNE) found at offset {offset}. \
728 Strip UNG/UNE wrappers before calling validate_envelope",
729 ))),
730 Self::ValidationErrors { error_count, .. } => Some(Box::new(format!(
731 "Validation found {error_count} error(s). Inspect the ValidationReport for details",
732 ))),
733 }
734 }
735}
736
737#[non_exhaustive]
744#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
745#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
746pub enum ValidationSeverity {
747 Critical,
749 Error,
751 Warning,
753 Info,
755}
756
757impl ValidationSeverity {
758 #[must_use]
765 pub fn as_str(self) -> &'static str {
766 match self {
767 Self::Critical => "critical",
768 Self::Error => "error",
769 Self::Warning => "warning",
770 Self::Info => "info",
771 #[allow(unreachable_patterns)]
772 _ => "unknown",
773 }
774 }
775
776 #[must_use]
781 pub fn numeric_level(self) -> u8 {
782 match self {
783 Self::Info => 0,
784 Self::Warning => 1,
785 Self::Error => 2,
786 Self::Critical => 3,
787 }
788 }
789}
790
791impl std::fmt::Display for ValidationSeverity {
792 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793 f.write_str(self.as_str())
794 }
795}
796
797#[derive(Debug, Clone, PartialEq)]
804#[non_exhaustive]
805#[cfg_attr(feature = "serde", derive(serde::Serialize))]
806pub struct ValidationIssue {
807 pub error_code: Option<&'static str>,
809 pub severity: ValidationSeverity,
811 pub message: String,
813 pub offset: Option<usize>,
815 pub segment_tag: Option<String>,
817 pub rule_id: Option<String>,
819 pub element_index: Option<u8>,
824 pub component_index: Option<u8>,
829 pub segment_occurrence: Option<u16>,
835 pub message_ref: Option<String>,
842 pub suggestion: Option<String>,
844 pub segment_group: Option<Arc<str>>,
850}
851
852impl ValidationIssue {
853 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
855 Self {
856 error_code: None,
857 severity,
858 message: message.into(),
859 offset: None,
860 segment_tag: None,
861 rule_id: None,
862 element_index: None,
863 component_index: None,
864 segment_occurrence: None,
865 message_ref: None,
866 suggestion: None,
867 segment_group: None,
868 }
869 }
870
871 pub fn with_error_code(mut self, code: &'static str) -> Self {
873 self.error_code = Some(code);
874 self
875 }
876
877 pub fn with_offset(mut self, offset: usize) -> Self {
879 self.offset = Some(offset);
880 self
881 }
882
883 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
885 self.segment_tag = Some(tag.into());
886 self
887 }
888
889 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
891 self.rule_id = Some(rule_id.into());
892 self
893 }
894
895 pub fn with_element_index(mut self, element_index: u8) -> Self {
897 self.element_index = Some(element_index);
898 self
899 }
900
901 pub fn with_component_index(mut self, component_index: u8) -> Self {
903 self.component_index = Some(component_index);
904 self
905 }
906
907 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
909 self.suggestion = Some(suggestion.into());
910 self
911 }
912
913 pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
918 self.segment_occurrence = Some(occurrence);
919 self
920 }
921
922 pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
927 self.message_ref = Some(message_ref.into());
928 self
929 }
930
931 pub fn with_segment_group(mut self, group: impl Into<Arc<str>>) -> Self {
937 self.segment_group = Some(group.into());
938 self
939 }
940
941 #[must_use]
943 pub fn severity_label(&self) -> &'static str {
944 match self.severity {
945 ValidationSeverity::Critical => "CRITICAL",
946 ValidationSeverity::Error => "ERROR",
947 ValidationSeverity::Warning => "WARNING",
948 ValidationSeverity::Info => "INFO",
949 }
950 }
951
952 #[must_use]
956 #[inline]
957 pub fn error_code(&self) -> Option<&'static str> {
958 self.error_code
959 }
960
961 #[must_use]
963 #[inline]
964 pub fn offset(&self) -> Option<usize> {
965 self.offset
966 }
967
968 #[must_use]
970 #[inline]
971 pub fn segment_tag(&self) -> Option<&str> {
972 self.segment_tag.as_deref()
973 }
974
975 #[must_use]
977 #[inline]
978 pub fn rule_id(&self) -> Option<&str> {
979 self.rule_id.as_deref()
980 }
981
982 #[must_use]
984 #[inline]
985 pub fn element_index(&self) -> Option<u8> {
986 self.element_index
987 }
988
989 #[must_use]
991 #[inline]
992 pub fn component_index(&self) -> Option<u8> {
993 self.component_index
994 }
995
996 #[must_use]
998 #[inline]
999 pub fn segment_occurrence(&self) -> Option<u16> {
1000 self.segment_occurrence
1001 }
1002
1003 #[must_use]
1005 #[inline]
1006 pub fn message_ref(&self) -> Option<&str> {
1007 self.message_ref.as_deref()
1008 }
1009
1010 #[must_use]
1012 #[inline]
1013 pub fn suggestion(&self) -> Option<&str> {
1014 self.suggestion.as_deref()
1015 }
1016
1017 #[must_use]
1019 #[inline]
1020 pub fn segment_group(&self) -> Option<&str> {
1021 self.segment_group.as_deref()
1022 }
1023}
1024
1025impl std::fmt::Display for ValidationIssue {
1026 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027 write!(f, "[{}] {}", self.severity_label(), self.message)
1028 }
1029}
1030
1031impl std::error::Error for ValidationIssue {}
1032
1033#[derive(Debug, Clone, Default, PartialEq)]
1037#[cfg_attr(feature = "serde", derive(serde::Serialize))]
1038pub struct ValidationReport {
1039 pub(crate) errors: Vec<ValidationIssue>,
1041 pub(crate) warnings: Vec<ValidationIssue>,
1043 pub(crate) infos: Vec<ValidationIssue>,
1045}
1046
1047impl ValidationReport {
1048 pub fn errors(&self) -> &[ValidationIssue] {
1050 &self.errors
1051 }
1052
1053 pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
1055 &mut self.errors
1056 }
1057
1058 pub fn warnings(&self) -> &[ValidationIssue] {
1060 &self.warnings
1061 }
1062
1063 pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
1065 &mut self.warnings
1066 }
1067
1068 pub fn infos(&self) -> &[ValidationIssue] {
1070 &self.infos
1071 }
1072
1073 pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
1075 &mut self.infos
1076 }
1077 pub fn add_error(&mut self, issue: ValidationIssue) {
1079 self.errors.push(issue);
1080 }
1081
1082 pub fn add_warning(&mut self, issue: ValidationIssue) {
1084 self.warnings.push(issue);
1085 }
1086
1087 pub fn add_info(&mut self, issue: ValidationIssue) {
1089 self.infos.push(issue);
1090 }
1091
1092 pub fn has_errors(&self) -> bool {
1094 !self.errors().is_empty()
1095 }
1096
1097 pub fn has_warnings(&self) -> bool {
1099 !self.warnings().is_empty()
1100 }
1101
1102 pub fn total_issues(&self) -> usize {
1104 self.errors().len() + self.warnings().len() + self.infos().len()
1105 }
1106
1107 pub fn is_valid(&self) -> bool {
1109 self.errors().is_empty()
1110 }
1111
1112 pub fn result(self) -> Result<Self, Self> {
1118 if self.is_valid() { Ok(self) } else { Err(self) }
1119 }
1120
1121 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
1123 self.errors()
1124 .iter()
1125 .chain(self.warnings().iter())
1126 .chain(self.infos().iter())
1127 }
1128
1129 pub fn has_any_issues(&self) -> bool {
1131 !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
1132 }
1133
1134 pub fn merge(&mut self, mut other: ValidationReport) {
1139 self.errors.append(&mut other.errors);
1140 self.warnings.append(&mut other.warnings);
1141 self.infos.append(&mut other.infos);
1142 }
1143
1144 pub fn issues_for_rule_id<'a>(
1149 &'a self,
1150 rule_id: &'a str,
1151 ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
1152 self.iter_issues()
1153 .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
1154 }
1155
1156 fn filter_report<F>(&self, pred: F) -> Self
1158 where
1159 F: Fn(&ValidationIssue) -> bool,
1160 {
1161 Self {
1162 errors: self.errors().iter().filter(|i| pred(i)).cloned().collect(),
1163 warnings: self
1164 .warnings()
1165 .iter()
1166 .filter(|i| pred(i))
1167 .cloned()
1168 .collect(),
1169 infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
1170 }
1171 }
1172
1173 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
1175 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
1176 }
1177
1178 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
1180 self.filter_report(|issue| {
1181 issue
1182 .rule_id
1183 .as_deref()
1184 .is_some_and(|id| id.starts_with(prefix))
1185 })
1186 }
1187
1188 pub fn for_segment(&self, segment_tag: &str) -> Self {
1212 self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
1213 }
1214
1215 pub fn render_deterministic(&self) -> String {
1217 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
1218 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
1219 refs.sort_by(|left, right| {
1220 left.offset
1221 .unwrap_or(usize::MAX)
1222 .cmp(&right.offset.unwrap_or(usize::MAX))
1223 .then_with(|| {
1224 left.segment_tag
1225 .as_deref()
1226 .unwrap_or("")
1227 .cmp(right.segment_tag.as_deref().unwrap_or(""))
1228 })
1229 .then_with(|| {
1230 left.rule_id
1231 .as_deref()
1232 .unwrap_or("")
1233 .cmp(right.rule_id.as_deref().unwrap_or(""))
1234 })
1235 .then_with(|| {
1236 left.element_index
1237 .unwrap_or(u8::MAX)
1238 .cmp(&right.element_index.unwrap_or(u8::MAX))
1239 })
1240 .then_with(|| {
1241 left.component_index
1242 .unwrap_or(u8::MAX)
1243 .cmp(&right.component_index.unwrap_or(u8::MAX))
1244 })
1245 .then_with(|| {
1246 left.error_code
1247 .unwrap_or("")
1248 .cmp(right.error_code.unwrap_or(""))
1249 })
1250 .then_with(|| left.message.cmp(&right.message))
1251 });
1252 refs
1253 }
1254
1255 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
1256 use std::fmt::Write as _;
1257 out.push_str(" - ");
1258 out.push_str(&issue.message);
1259 if let Some(code) = issue.error_code {
1260 out.push_str(" [");
1261 out.push_str(code);
1262 out.push(']');
1263 }
1264 if let Some(seg) = &issue.segment_tag {
1265 out.push_str(" [segment=");
1266 out.push_str(seg);
1267 out.push(']');
1268 }
1269 if let Some(rule_id) = &issue.rule_id {
1270 out.push_str(" [rule=");
1271 out.push_str(rule_id);
1272 out.push(']');
1273 }
1274 if let Some(element_index) = issue.element_index {
1275 write!(out, " [element={element_index}]").ok();
1276 }
1277 if let Some(component_index) = issue.component_index {
1278 write!(out, " [component={component_index}]").ok();
1279 }
1280 if let Some(offset) = issue.offset {
1281 write!(out, " [offset={offset}]").ok();
1282 }
1283 if let Some(suggestion) = &issue.suggestion {
1284 out.push_str(" [hint=");
1285 out.push_str(suggestion);
1286 out.push(']');
1287 }
1288 }
1289
1290 use std::fmt::Write as _;
1291 let mut out = String::from("Validation Report:");
1292 let errors = sorted_refs(self.errors());
1293 let warnings = sorted_refs(self.warnings());
1294 let infos = sorted_refs(self.infos());
1295
1296 if !errors.is_empty() {
1297 write!(out, "\n Errors ({})", errors.len()).ok();
1298 for issue in &errors {
1299 out.push('\n');
1300 render_issue_line(&mut out, issue);
1301 }
1302 }
1303 if !warnings.is_empty() {
1304 write!(out, "\n Warnings ({})", warnings.len()).ok();
1305 for issue in &warnings {
1306 out.push('\n');
1307 render_issue_line(&mut out, issue);
1308 }
1309 }
1310 if !infos.is_empty() {
1311 write!(out, "\n Info ({})", infos.len()).ok();
1312 for issue in &infos {
1313 out.push('\n');
1314 render_issue_line(&mut out, issue);
1315 }
1316 }
1317
1318 out
1319 }
1320}
1321
1322#[cfg(feature = "diagnostics")]
1323impl miette::Diagnostic for ValidationReport {
1324 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1325 Some(Box::new("VALIDATION"))
1326 }
1327
1328 fn severity(&self) -> Option<miette::Severity> {
1329 if self.has_errors() {
1330 Some(miette::Severity::Error)
1331 } else if self.has_warnings() {
1332 Some(miette::Severity::Warning)
1333 } else {
1334 Some(miette::Severity::Advice)
1335 }
1336 }
1337
1338 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1339 let msg = format!(
1340 "Validation found {} error(s), {} warning(s), {} info(s)",
1341 self.errors().len(),
1342 self.warnings().len(),
1343 self.infos().len()
1344 );
1345 Some(Box::new(msg))
1346 }
1347}
1348
1349impl std::fmt::Display for ValidationReport {
1350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1351 write!(f, "{}", self.render_deterministic())
1352 }
1353}
1354
1355impl std::error::Error for ValidationReport {}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360
1361 #[test]
1362 fn validation_report_collects_errors() {
1363 let mut report = ValidationReport::default();
1364 report.add_error(
1365 ValidationIssue::new(ValidationSeverity::Error, "Test error")
1366 .with_segment("BGM")
1367 .with_offset(42),
1368 );
1369 report.add_warning(ValidationIssue::new(
1370 ValidationSeverity::Warning,
1371 "Test warning",
1372 ));
1373
1374 assert!(report.has_errors());
1375 assert!(report.has_warnings());
1376 assert_eq!(report.total_issues(), 2);
1377 assert!(!report.is_valid());
1378 }
1379
1380 #[test]
1381 fn validation_report_result_conversion() {
1382 let mut report = ValidationReport::default();
1383 report.add_error(ValidationIssue::new(
1384 ValidationSeverity::Error,
1385 "Critical issue",
1386 ));
1387
1388 let result = report.result();
1389 assert!(result.is_err());
1390 }
1391
1392 #[test]
1393 fn validation_report_passes_when_no_errors() {
1394 let mut report = ValidationReport::default();
1395 report.add_warning(ValidationIssue::new(
1396 ValidationSeverity::Warning,
1397 "Just a warning",
1398 ));
1399
1400 assert!(report.is_valid());
1401 assert!(report.result().is_ok());
1402 }
1403
1404 #[test]
1405 fn validation_issue_builder() {
1406 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1407 .with_error_code("E013")
1408 .with_offset(100)
1409 .with_segment("NAD")
1410 .with_rule_id("DEMO-P001")
1411 .with_element_index(1)
1412 .with_component_index(2)
1413 .with_suggestion("Check element count");
1414
1415 assert_eq!(issue.error_code, Some("E013"));
1416 assert_eq!(issue.message, "test message");
1417 assert_eq!(issue.offset, Some(100));
1418 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1419 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1420 assert_eq!(issue.element_index, Some(1));
1421 assert_eq!(issue.component_index, Some(2));
1422 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1423 }
1424
1425 #[test]
1426 fn validation_report_display() {
1427 let mut report = ValidationReport::default();
1428 report.add_error(
1429 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1430 .with_error_code("E011")
1431 .with_offset(8),
1432 );
1433 report.add_warning(ValidationIssue::new(
1434 ValidationSeverity::Warning,
1435 "Warning 1",
1436 ));
1437 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1438
1439 let display_str = format!("{}", report);
1440 assert!(display_str.contains("Errors (1)"));
1441 assert!(display_str.contains("Warnings (1)"));
1442 assert!(display_str.contains("Info (1)"));
1443 assert!(display_str.contains("[E011]"));
1444 }
1445
1446 #[test]
1447 fn validation_report_render_is_deterministic() {
1448 let mut report = ValidationReport::default();
1449 report.add_error(
1450 ValidationIssue::new(ValidationSeverity::Error, "later")
1451 .with_segment("BGM")
1452 .with_offset(20),
1453 );
1454 report.add_error(
1455 ValidationIssue::new(ValidationSeverity::Error, "earlier")
1456 .with_segment("UNH")
1457 .with_offset(1),
1458 );
1459
1460 let rendered = report.render_deterministic();
1461 let first = rendered.find("earlier").expect("missing first issue");
1462 let second = rendered.find("later").expect("missing second issue");
1463 assert!(first < second, "expected deterministic sort by offset");
1464 }
1465
1466 #[test]
1467 fn recovery_hint_exists_for_common_malformed_cases() {
1468 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1469 assert!(err.recovery_hint().is_some());
1470
1471 let err = EdifactError::InvalidCodeValue {
1472 tag: "BGM".to_owned(),
1473 element_index: 0,
1474 value: "X".to_owned(),
1475 code_list: "1001".to_owned(),
1476 offset: 0,
1477 suggestion: None,
1478 };
1479 assert!(err.recovery_hint().is_some());
1480 }
1481
1482 #[test]
1483 fn validation_report_can_filter_by_rule_id() {
1484 let mut report = ValidationReport::default();
1485 report.add_error(
1486 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1487 .with_rule_id("ORDERS-P001"),
1488 );
1489 report.add_warning(
1490 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1491 .with_rule_id("INVOIC-P001"),
1492 );
1493 report.add_info(
1494 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1495 .with_rule_id("ORDERS-P002"),
1496 );
1497
1498 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1499 assert_eq!(only_orders_block.errors().len(), 1);
1500 assert!(only_orders_block.warnings().is_empty());
1501 assert!(only_orders_block.infos().is_empty());
1502
1503 let orders_family = report.filter_by_rule_prefix("ORDERS-");
1504 assert_eq!(orders_family.total_issues(), 2);
1505 assert!(orders_family.has_errors());
1506 assert!(!orders_family.has_warnings());
1507
1508 let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1509 assert_eq!(exact.len(), 1);
1510 assert_eq!(exact[0].message, "invoic policy warning");
1511 }
1512}