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 {
11 self.0.kind() == other.0.kind()
12 }
13}
14
15impl std::fmt::Display for IoError {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 self.0.fmt(f)
18 }
19}
20
21impl std::error::Error for IoError {
22 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
23 self.0.source()
24 }
25}
26
27impl From<std::io::Error> for IoError {
28 fn from(e: std::io::Error) -> Self {
29 Self(e)
30 }
31}
32
33#[derive(Debug, Error, PartialEq)]
40#[non_exhaustive]
41pub enum EdifactError {
42 #[error("unexpected end of input at byte offset {offset}")]
47 UnexpectedEof {
48 offset: usize,
50 },
51
52 #[error("invalid delimiter byte 0x{byte:02X} at offset {offset}")]
57 InvalidDelimiter {
58 byte: u8,
60 offset: usize,
62 },
63
64 #[error("invalid EDIFACT text at byte offset {offset}")]
69 InvalidText {
70 offset: usize,
72 },
73
74 #[error("invalid release sequence at byte offset {offset}: dangling release character")]
79 InvalidReleaseSequence {
80 offset: usize,
82 },
83
84 #[error("interchange message count mismatch: UNZ declared {expected}, found {actual}")]
89 MessageCountMismatch {
90 expected: u32,
92 actual: u32,
94 },
95
96 #[error(
101 "segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}"
102 )]
103 SegmentCountMismatch {
104 expected: u32,
106 actual: u32,
108 message_ref: String,
110 },
111
112 #[error("invalid segment tag {0:?}")]
116 InvalidSegmentTag(String),
117
118 #[error("invalid UNA service string advice")]
126 InvalidUna,
127
128 #[error("missing required element {element_index} in segment {tag}")]
133 MissingRequiredElement {
134 tag: String,
136 element_index: usize,
138 },
139
140 #[error(
144 "missing required component {component_index} in element {element_index} of segment {tag}"
145 )]
146 MissingRequiredComponent {
147 tag: String,
149 element_index: usize,
151 component_index: usize,
153 },
154
155 #[error("serialized output contains invalid UTF-8")]
160 InvalidUtf8,
161
162 #[error(transparent)]
164 Io(#[from] IoError),
165
166 #[error("segment {tag} is not valid for message type {message_type}")]
171 InvalidSegmentForMessage {
172 tag: String,
174 message_type: String,
176 offset: usize,
178 },
179
180 #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
184 InvalidElementCount {
185 tag: String,
187 min: usize,
189 max: usize,
191 actual: usize,
193 offset: usize,
195 },
196
197 #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
201 InvalidComponentCount {
202 tag: String,
204 element_index: usize,
206 expected: u8,
208 actual: u8,
210 offset: usize,
212 },
213
214 #[error(
219 "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
220 )]
221 InvalidCodeValue {
222 tag: String,
224 element_index: usize,
226 value: String,
228 code_list: String,
230 offset: usize,
232 suggestion: Option<&'static str>,
234 },
235
236 #[error("required segment {tag} is missing from message (position {expected_position})")]
240 MissingSegment {
241 tag: String,
243 expected_position: String,
245 },
246
247 #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
251 QualifierMismatch {
252 tag: String,
254 actual: String,
256 expected: String,
258 offset: usize,
260 },
261
262 #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
267 ConditionalRequirementNotMet {
268 tag: String,
270 element_index: usize,
272 condition: String,
274 offset: usize,
276 },
277
278 #[error("validation failed with {error_count} issue(s); first issue: {first_message}")]
280 ValidationFailed {
281 error_count: usize,
283 first_message: String,
285 },
286
287 #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
296 SegmentTooLong {
297 offset: usize,
299 limit: usize,
301 },
302
303 #[error("no handler registered for message type {message_type}")]
309 UnexpectedMessageType {
310 message_type: String,
312 },
313
314 #[error("interchange too large: count {count} exceeds u32::MAX")]
321 InterchangeTooLarge {
322 count: u64,
324 },
325
326 #[error("invalid event sequence: {message}")]
334 InvalidEventSequence {
335 message: &'static str,
337 },
338
339 #[error("element definition contains invalid position 0; positions must be >= 1 (one-based)")]
345 InvalidElementPosition,
346
347 #[error("incompatible release scopes: cannot compose {current:?} with {incoming:?}")]
354 IncompatibleReleaseScopes {
355 current: String,
357 incoming: String,
359 },
360}
361
362impl From<std::io::Error> for EdifactError {
363 fn from(e: std::io::Error) -> Self {
364 Self::Io(IoError(e))
365 }
366}
367
368impl EdifactError {
369 #[must_use]
371 pub const fn stable_code(&self) -> &'static str {
372 match self {
373 Self::UnexpectedEof { .. } => "E001",
374 Self::InvalidDelimiter { .. } => "E002",
375 Self::InvalidText { .. } => "E003",
376 Self::MessageCountMismatch { .. } => "E004",
377 Self::SegmentCountMismatch { .. } => "E005",
378 Self::InvalidSegmentTag(_) => "E006",
379 Self::InvalidUna => "E007",
380 Self::MissingRequiredElement { .. } => "E008",
381 Self::InvalidUtf8 => "E009",
382 Self::Io(_) => "E010",
383 Self::InvalidSegmentForMessage { .. } => "E011",
384 Self::InvalidElementCount { .. } => "E012",
385 Self::InvalidComponentCount { .. } => "E013",
386 Self::InvalidCodeValue { .. } => "E014",
387 Self::MissingSegment { .. } => "E015",
388 Self::QualifierMismatch { .. } => "E016",
389 Self::ConditionalRequirementNotMet { .. } => "E017",
390 Self::ValidationFailed { .. } => "E018",
391 Self::InvalidReleaseSequence { .. } => "E019",
392 Self::SegmentTooLong { .. } => "E020",
393 Self::MissingRequiredComponent { .. } => "E021",
394 Self::UnexpectedMessageType { .. } => "E022",
395 Self::InterchangeTooLarge { .. } => "E023",
396 Self::InvalidEventSequence { .. } => "E024",
397 Self::InvalidElementPosition => "E025",
398 Self::IncompatibleReleaseScopes { .. } => "E026",
399 }
400 }
401
402 #[must_use]
404 pub fn recovery_hint(&self) -> Option<&'static str> {
405 match self {
406 Self::UnexpectedEof { .. } => {
407 Some("Ensure every segment ends with the configured segment terminator")
408 }
409 Self::InvalidDelimiter { .. } => {
410 Some("Check UNA service string advice and delimiter bytes in the payload")
411 }
412 Self::InvalidText { .. } => {
413 Some("Input must be valid UTF-8 text for segment and element values")
414 }
415 Self::InvalidReleaseSequence { .. } => {
416 Some("Release character must escape one following byte; trailing '?' is invalid")
417 }
418 Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
419 Self::InvalidUna => Some(
420 "UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters",
421 ),
422 Self::MissingRequiredElement { .. } => {
423 Some("Provide all mandatory elements for the segment per directory rules")
424 }
425 Self::MissingRequiredComponent { .. } => Some(
426 "Provide all mandatory components for the composite element per directory rules",
427 ),
428 Self::InvalidSegmentForMessage { .. } => {
429 Some("Remove unsupported segment or switch to the correct message type")
430 }
431 Self::InvalidElementCount { .. } => {
432 Some("Adjust the segment element count to the allowed min/max range")
433 }
434 Self::InvalidComponentCount { .. } => {
435 Some("Fix composite element arity to match the expected component count")
436 }
437 Self::InvalidCodeValue { .. } => {
438 Some("Use a value from the referenced code list for this element")
439 }
440 Self::MissingSegment { .. } => {
441 Some("Insert the required segment at the expected position")
442 }
443 Self::QualifierMismatch { .. } => {
444 Some("Set the segment qualifier to the expected value")
445 }
446 Self::ConditionalRequirementNotMet { .. } => {
447 Some("When the condition is met, include the conditionally required element")
448 }
449 Self::SegmentTooLong { limit, .. } => {
450 let _ = limit; Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
452 }
453 Self::InvalidEventSequence { .. } => {
454 Some("Emit StartSegment before Element, and Element before ComponentElement")
455 }
456 Self::InvalidElementPosition => Some(
457 "Set element position to a value >= 1; positions are one-based (1 = first element slot)",
458 ),
459 Self::IncompatibleReleaseScopes { .. } => Some(
460 "Only compose ProfileRulePack values that share the same release scope, or where at most one has a release scope set",
461 ),
462 Self::ValidationFailed { .. }
463 | Self::MessageCountMismatch { .. }
464 | Self::SegmentCountMismatch { .. }
465 | Self::UnexpectedMessageType { .. }
466 | Self::InterchangeTooLarge { .. }
467 | Self::InvalidUtf8
468 | Self::Io(_) => None,
469 }
470 }
471}
472
473#[cfg(feature = "diagnostics")]
474#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
475impl miette::Diagnostic for EdifactError {
476 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
477 Some(Box::new(self.stable_code()))
478 }
479
480 fn severity(&self) -> Option<miette::Severity> {
481 match self {
482 Self::InvalidCodeValue { .. }
483 | Self::InvalidComponentCount { .. }
484 | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
485 _ => Some(miette::Severity::Error),
486 }
487 }
488
489 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
490 match self {
491 Self::InvalidUna => Some(Box::new(
493 "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
494 )),
495 Self::InvalidUtf8 => Some(Box::new(
496 "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
497 )),
498 Self::UnexpectedEof { offset } => Some(Box::new(format!(
500 "Check that all segments are terminated with the segment terminator (usually '). \
501 Reached end at offset {offset}",
502 ))),
503 Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
504 "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
505 Check UNA configuration",
506 ))),
507 Self::InvalidText { offset } => Some(Box::new(format!(
508 "The byte sequence at offset {offset} contains invalid UTF-8. \
509 Ensure input is valid UTF-8",
510 ))),
511 Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
512 "Release character at offset {offset} is dangling. \
513 Ensure '?' is followed by an escaped byte",
514 ))),
515 Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
516 "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
517 Check the UNZ message count",
518 ))),
519 Self::SegmentCountMismatch {
520 expected,
521 actual,
522 message_ref,
523 } => Some(Box::new(format!(
524 "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
525 Check the UNT segment count",
526 ))),
527 Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
528 "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
529 ))),
530 Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
531 "Segment {tag} requires element at index {element_index}",
532 ))),
533 Self::MissingRequiredComponent {
534 tag,
535 element_index,
536 component_index,
537 } => Some(Box::new(format!(
538 "Segment {tag} element {element_index} requires component at index {component_index}",
539 ))),
540 Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
541 Self::InvalidSegmentForMessage {
542 tag, message_type, ..
543 } => Some(Box::new(format!(
544 "Segment {tag} should not appear in a {message_type} message. \
545 Check the directory definition",
546 ))),
547 Self::InvalidElementCount {
548 tag,
549 min,
550 max,
551 actual,
552 ..
553 } => Some(Box::new(format!(
554 "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
555 Check segment structure",
556 ))),
557 Self::InvalidComponentCount {
558 tag,
559 element_index,
560 expected,
561 actual,
562 ..
563 } => Some(Box::new(format!(
564 "In segment {tag}, element {element_index} should have {expected} components \
565 but has {actual}. Check element structure",
566 ))),
567 Self::InvalidCodeValue {
568 tag,
569 element_index,
570 value,
571 code_list,
572 ..
573 } => Some(Box::new(format!(
574 "Value '{value}' in segment {tag} element {element_index} is not in the \
575 {code_list} code list. Check the directory for valid codes",
576 ))),
577 Self::MissingSegment {
578 tag,
579 expected_position,
580 } => Some(Box::new(format!(
581 "Segment {tag} is required at position {expected_position} but is missing. \
582 Add this segment to the message",
583 ))),
584 Self::QualifierMismatch {
585 tag,
586 actual,
587 expected,
588 ..
589 } => Some(Box::new(format!(
590 "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
591 Check the segment's first component",
592 ))),
593 Self::ConditionalRequirementNotMet {
594 tag,
595 element_index,
596 condition,
597 ..
598 } => Some(Box::new(format!(
599 "In segment {tag}, element {element_index} is conditionally required when: \
600 {condition}. Check if the condition is met",
601 ))),
602 Self::ValidationFailed {
603 error_count,
604 first_message,
605 } => Some(Box::new(format!(
606 "Validation found {error_count} issue(s). Start by fixing: {first_message}",
607 ))),
608 Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
609 "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
610 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
611 or verify the input for a missing segment terminator",
612 ))),
613 Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
614 "No handler was registered for message type '{message_type}'. \
615 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
616 ))),
617 Self::InterchangeTooLarge { count } => Some(Box::new(format!(
618 "Interchange contains {count} items which exceeds the u32::MAX limit. \
619 This is an extremely unusual input; verify the message is not corrupted.",
620 ))),
621 Self::InvalidEventSequence { message } => Some(Box::new(format!(
622 "Event sequence violation: {message}. \
623 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
624 ))),
625 Self::InvalidElementPosition => Some(Box::new(
626 "Element positions must be >= 1 (one-based). \
627 Ensure no OwnedElementRef is constructed with position == 0",
628 )),
629 Self::IncompatibleReleaseScopes { current, incoming } => Some(Box::new(format!(
630 "Release scope {current:?} and {incoming:?} are incompatible. \
631 Only compose ProfileRulePack values that share the same release scope, \
632 or where at most one carries a release scope",
633 ))),
634 }
635 }
636}
637
638#[non_exhaustive]
645#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
646pub enum ValidationSeverity {
647 Critical,
649 Error,
651 Warning,
653 Info,
655}
656
657impl std::fmt::Display for ValidationSeverity {
658 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659 match self {
660 Self::Critical => f.write_str("critical"),
661 Self::Error => f.write_str("error"),
662 Self::Warning => f.write_str("warning"),
663 Self::Info => f.write_str("info"),
664 }
665 }
666}
667
668#[derive(Debug, Clone, PartialEq)]
670pub struct ValidationIssue {
671 pub error_code: Option<&'static str>,
673 pub severity: ValidationSeverity,
675 pub message: String,
677 pub offset: Option<usize>,
679 pub segment_tag: Option<String>,
681 pub rule_id: Option<String>,
683 pub element_index: Option<u8>,
688 pub component_index: Option<u8>,
693 pub suggestion: Option<String>,
695}
696
697impl ValidationIssue {
698 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
700 Self {
701 error_code: None,
702 severity,
703 message: message.into(),
704 offset: None,
705 segment_tag: None,
706 rule_id: None,
707 element_index: None,
708 component_index: None,
709 suggestion: None,
710 }
711 }
712
713 pub fn with_error_code(mut self, code: &'static str) -> Self {
715 self.error_code = Some(code);
716 self
717 }
718
719 pub fn with_offset(mut self, offset: usize) -> Self {
721 self.offset = Some(offset);
722 self
723 }
724
725 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
727 self.segment_tag = Some(tag.into());
728 self
729 }
730
731 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
733 self.rule_id = Some(rule_id.into());
734 self
735 }
736
737 pub fn with_element_index(mut self, element_index: u8) -> Self {
739 self.element_index = Some(element_index);
740 self
741 }
742
743 pub fn with_component_index(mut self, component_index: u8) -> Self {
745 self.component_index = Some(component_index);
746 self
747 }
748
749 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
751 self.suggestion = Some(suggestion.into());
752 self
753 }
754
755 #[must_use]
757 pub fn severity_label(&self) -> &'static str {
758 match self.severity {
759 ValidationSeverity::Critical => "CRITICAL",
760 ValidationSeverity::Error => "ERROR",
761 ValidationSeverity::Warning => "WARNING",
762 ValidationSeverity::Info => "INFO",
763 }
764 }
765}
766
767impl std::fmt::Display for ValidationIssue {
768 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
769 write!(f, "[{}] {}", self.severity_label(), self.message)
770 }
771}
772
773impl std::error::Error for ValidationIssue {}
774
775#[derive(Debug, Clone, Default)]
779pub struct ValidationReport {
780 pub(crate) errors: Vec<ValidationIssue>,
782 pub(crate) warnings: Vec<ValidationIssue>,
784 pub(crate) infos: Vec<ValidationIssue>,
786}
787
788impl ValidationReport {
789 pub fn errors(&self) -> &[ValidationIssue] {
791 &self.errors
792 }
793
794 pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
796 &mut self.errors
797 }
798
799 pub fn warnings(&self) -> &[ValidationIssue] {
801 &self.warnings
802 }
803
804 pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
806 &mut self.warnings
807 }
808
809 pub fn infos(&self) -> &[ValidationIssue] {
811 &self.infos
812 }
813
814 pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
816 &mut self.infos
817 }
818 pub fn add_error(&mut self, issue: ValidationIssue) {
820 self.errors.push(issue);
821 }
822
823 pub fn add_warning(&mut self, issue: ValidationIssue) {
825 self.warnings.push(issue);
826 }
827
828 pub fn add_info(&mut self, issue: ValidationIssue) {
830 self.infos.push(issue);
831 }
832
833 pub fn has_errors(&self) -> bool {
835 !self.errors().is_empty()
836 }
837
838 pub fn has_warnings(&self) -> bool {
840 !self.warnings().is_empty()
841 }
842
843 pub fn total_issues(&self) -> usize {
845 self.errors().len() + self.warnings().len() + self.infos().len()
846 }
847
848 pub fn is_valid(&self) -> bool {
850 self.errors().is_empty()
851 }
852
853 pub fn result(self) -> Result<Self, Self> {
859 if self.is_valid() { Ok(self) } else { Err(self) }
860 }
861
862 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
864 self.errors()
865 .iter()
866 .chain(self.warnings().iter())
867 .chain(self.infos().iter())
868 }
869
870 pub fn has_any_issues(&self) -> bool {
872 !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
873 }
874
875 pub fn issues_for_rule_id<'a>(
880 &'a self,
881 rule_id: &'a str,
882 ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
883 self.iter_issues()
884 .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
885 }
886
887 fn filter_report<F>(&self, pred: F) -> Self
889 where
890 F: Fn(&ValidationIssue) -> bool,
891 {
892 Self {
893 errors: self.errors().iter().filter(|i| pred(i)).cloned().collect(),
894 warnings: self
895 .warnings()
896 .iter()
897 .filter(|i| pred(i))
898 .cloned()
899 .collect(),
900 infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
901 }
902 }
903
904 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
906 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
907 }
908
909 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
911 self.filter_report(|issue| {
912 issue
913 .rule_id
914 .as_deref()
915 .is_some_and(|id| id.starts_with(prefix))
916 })
917 }
918
919 pub fn for_segment(&self, segment_tag: &str) -> Self {
943 self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
944 }
945
946 pub fn render_deterministic(&self) -> String {
948 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
949 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
950 refs.sort_by(|left, right| {
951 left.offset
952 .unwrap_or(usize::MAX)
953 .cmp(&right.offset.unwrap_or(usize::MAX))
954 .then_with(|| {
955 left.segment_tag
956 .as_deref()
957 .unwrap_or("")
958 .cmp(right.segment_tag.as_deref().unwrap_or(""))
959 })
960 .then_with(|| {
961 left.rule_id
962 .as_deref()
963 .unwrap_or("")
964 .cmp(right.rule_id.as_deref().unwrap_or(""))
965 })
966 .then_with(|| {
967 left.element_index
968 .unwrap_or(u8::MAX)
969 .cmp(&right.element_index.unwrap_or(u8::MAX))
970 })
971 .then_with(|| {
972 left.component_index
973 .unwrap_or(u8::MAX)
974 .cmp(&right.component_index.unwrap_or(u8::MAX))
975 })
976 .then_with(|| {
977 left.error_code
978 .unwrap_or("")
979 .cmp(right.error_code.unwrap_or(""))
980 })
981 .then_with(|| left.message.cmp(&right.message))
982 });
983 refs
984 }
985
986 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
987 use std::fmt::Write as _;
988 out.push_str(" - ");
989 out.push_str(&issue.message);
990 if let Some(code) = issue.error_code {
991 out.push_str(" [");
992 out.push_str(code);
993 out.push(']');
994 }
995 if let Some(seg) = &issue.segment_tag {
996 out.push_str(" [segment=");
997 out.push_str(seg);
998 out.push(']');
999 }
1000 if let Some(rule_id) = &issue.rule_id {
1001 out.push_str(" [rule=");
1002 out.push_str(rule_id);
1003 out.push(']');
1004 }
1005 if let Some(element_index) = issue.element_index {
1006 write!(out, " [element={element_index}]").ok();
1007 }
1008 if let Some(component_index) = issue.component_index {
1009 write!(out, " [component={component_index}]").ok();
1010 }
1011 if let Some(offset) = issue.offset {
1012 write!(out, " [offset={offset}]").ok();
1013 }
1014 if let Some(suggestion) = &issue.suggestion {
1015 out.push_str(" [hint=");
1016 out.push_str(suggestion);
1017 out.push(']');
1018 }
1019 }
1020
1021 use std::fmt::Write as _;
1022 let mut out = String::from("Validation Report:");
1023 let errors = sorted_refs(self.errors());
1024 let warnings = sorted_refs(self.warnings());
1025 let infos = sorted_refs(self.infos());
1026
1027 if !errors.is_empty() {
1028 write!(out, "\n Errors ({})", errors.len()).ok();
1029 for issue in &errors {
1030 out.push('\n');
1031 render_issue_line(&mut out, issue);
1032 }
1033 }
1034 if !warnings.is_empty() {
1035 write!(out, "\n Warnings ({})", warnings.len()).ok();
1036 for issue in &warnings {
1037 out.push('\n');
1038 render_issue_line(&mut out, issue);
1039 }
1040 }
1041 if !infos.is_empty() {
1042 write!(out, "\n Info ({})", infos.len()).ok();
1043 for issue in &infos {
1044 out.push('\n');
1045 render_issue_line(&mut out, issue);
1046 }
1047 }
1048
1049 out
1050 }
1051}
1052
1053#[cfg(feature = "diagnostics")]
1054impl miette::Diagnostic for ValidationReport {
1055 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1056 Some(Box::new("VALIDATION"))
1057 }
1058
1059 fn severity(&self) -> Option<miette::Severity> {
1060 if self.has_errors() {
1061 Some(miette::Severity::Error)
1062 } else if self.has_warnings() {
1063 Some(miette::Severity::Warning)
1064 } else {
1065 Some(miette::Severity::Advice)
1066 }
1067 }
1068
1069 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
1070 let msg = format!(
1071 "Validation found {} error(s), {} warning(s), {} info(s)",
1072 self.errors().len(),
1073 self.warnings().len(),
1074 self.infos().len()
1075 );
1076 Some(Box::new(msg))
1077 }
1078}
1079
1080impl std::fmt::Display for ValidationReport {
1081 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1082 write!(f, "{}", self.render_deterministic())
1083 }
1084}
1085
1086impl std::error::Error for ValidationReport {}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 #[test]
1093 fn validation_report_collects_errors() {
1094 let mut report = ValidationReport::default();
1095 report.add_error(
1096 ValidationIssue::new(ValidationSeverity::Error, "Test error")
1097 .with_segment("BGM")
1098 .with_offset(42),
1099 );
1100 report.add_warning(ValidationIssue::new(
1101 ValidationSeverity::Warning,
1102 "Test warning",
1103 ));
1104
1105 assert!(report.has_errors());
1106 assert!(report.has_warnings());
1107 assert_eq!(report.total_issues(), 2);
1108 assert!(!report.is_valid());
1109 }
1110
1111 #[test]
1112 fn validation_report_result_conversion() {
1113 let mut report = ValidationReport::default();
1114 report.add_error(ValidationIssue::new(
1115 ValidationSeverity::Error,
1116 "Critical issue",
1117 ));
1118
1119 let result = report.result();
1120 assert!(result.is_err());
1121 }
1122
1123 #[test]
1124 fn validation_report_passes_when_no_errors() {
1125 let mut report = ValidationReport::default();
1126 report.add_warning(ValidationIssue::new(
1127 ValidationSeverity::Warning,
1128 "Just a warning",
1129 ));
1130
1131 assert!(report.is_valid());
1132 assert!(report.result().is_ok());
1133 }
1134
1135 #[test]
1136 fn validation_issue_builder() {
1137 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1138 .with_error_code("E013")
1139 .with_offset(100)
1140 .with_segment("NAD")
1141 .with_rule_id("DEMO-P001")
1142 .with_element_index(1)
1143 .with_component_index(2)
1144 .with_suggestion("Check element count");
1145
1146 assert_eq!(issue.error_code, Some("E013"));
1147 assert_eq!(issue.message, "test message");
1148 assert_eq!(issue.offset, Some(100));
1149 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1150 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1151 assert_eq!(issue.element_index, Some(1));
1152 assert_eq!(issue.component_index, Some(2));
1153 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1154 }
1155
1156 #[test]
1157 fn validation_report_display() {
1158 let mut report = ValidationReport::default();
1159 report.add_error(
1160 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1161 .with_error_code("E011")
1162 .with_offset(8),
1163 );
1164 report.add_warning(ValidationIssue::new(
1165 ValidationSeverity::Warning,
1166 "Warning 1",
1167 ));
1168 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1169
1170 let display_str = format!("{}", report);
1171 assert!(display_str.contains("Errors (1)"));
1172 assert!(display_str.contains("Warnings (1)"));
1173 assert!(display_str.contains("Info (1)"));
1174 assert!(display_str.contains("[E011]"));
1175 }
1176
1177 #[test]
1178 fn validation_report_render_is_deterministic() {
1179 let mut report = ValidationReport::default();
1180 report.add_error(
1181 ValidationIssue::new(ValidationSeverity::Error, "later")
1182 .with_segment("BGM")
1183 .with_offset(20),
1184 );
1185 report.add_error(
1186 ValidationIssue::new(ValidationSeverity::Error, "earlier")
1187 .with_segment("UNH")
1188 .with_offset(1),
1189 );
1190
1191 let rendered = report.render_deterministic();
1192 let first = rendered.find("earlier").expect("missing first issue");
1193 let second = rendered.find("later").expect("missing second issue");
1194 assert!(first < second, "expected deterministic sort by offset");
1195 }
1196
1197 #[test]
1198 fn recovery_hint_exists_for_common_malformed_cases() {
1199 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1200 assert!(err.recovery_hint().is_some());
1201
1202 let err = EdifactError::InvalidCodeValue {
1203 tag: "BGM".to_owned(),
1204 element_index: 0,
1205 value: "X".to_owned(),
1206 code_list: "1001".to_owned(),
1207 offset: 0,
1208 suggestion: None,
1209 };
1210 assert!(err.recovery_hint().is_some());
1211 }
1212
1213 #[test]
1214 fn validation_report_can_filter_by_rule_id() {
1215 let mut report = ValidationReport::default();
1216 report.add_error(
1217 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1218 .with_rule_id("ORDERS-P001"),
1219 );
1220 report.add_warning(
1221 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1222 .with_rule_id("INVOIC-P001"),
1223 );
1224 report.add_info(
1225 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1226 .with_rule_id("ORDERS-P002"),
1227 );
1228
1229 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1230 assert_eq!(only_orders_block.errors().len(), 1);
1231 assert!(only_orders_block.warnings().is_empty());
1232 assert!(only_orders_block.infos().is_empty());
1233
1234 let orders_family = report.filter_by_rule_prefix("ORDERS-");
1235 assert_eq!(orders_family.total_issues(), 2);
1236 assert!(orders_family.has_errors());
1237 assert!(!orders_family.has_warnings());
1238
1239 let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1240 assert_eq!(exact.len(), 1);
1241 assert_eq!(exact[0].message, "invoic policy warning");
1242 }
1243}