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("segment count mismatch in message {message_ref}: UNT declared {expected}, found {actual}")]
101 SegmentCountMismatch {
102 expected: u32,
104 actual: u32,
106 message_ref: String,
108 },
109
110 #[error("invalid segment tag {0:?}")]
114 InvalidSegmentTag(String),
115
116 #[error("invalid UNA service string advice")]
124 InvalidUna,
125
126 #[error("missing required element {element_index} in segment {tag}")]
131 MissingRequiredElement {
132 tag: String,
134 element_index: usize,
136 },
137
138 #[error(
142 "missing required component {component_index} in element {element_index} of segment {tag}"
143 )]
144 MissingRequiredComponent {
145 tag: String,
147 element_index: usize,
149 component_index: usize,
151 },
152
153 #[error("serialized output contains invalid UTF-8")]
158 InvalidUtf8,
159
160 #[error(transparent)]
162 Io(#[from] IoError),
163
164 #[error("segment {tag} is not valid for message type {message_type}")]
169 InvalidSegmentForMessage {
170 tag: String,
172 message_type: String,
174 offset: usize,
176 },
177
178 #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
182 InvalidElementCount {
183 tag: String,
185 min: usize,
187 max: usize,
189 actual: usize,
191 offset: usize,
193 },
194
195 #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
199 InvalidComponentCount {
200 tag: String,
202 element_index: usize,
204 expected: u8,
206 actual: u8,
208 offset: usize,
210 },
211
212 #[error(
217 "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
218 )]
219 InvalidCodeValue {
220 tag: String,
222 element_index: usize,
224 value: String,
226 code_list: String,
228 offset: usize,
230 suggestion: Option<&'static str>,
232 },
233
234 #[error("required segment {tag} is missing from message (position {expected_position})")]
238 MissingSegment {
239 tag: String,
241 expected_position: String,
243 },
244
245 #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
249 QualifierMismatch {
250 tag: String,
252 actual: String,
254 expected: String,
256 offset: usize,
258 },
259
260 #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
265 ConditionalRequirementNotMet {
266 tag: String,
268 element_index: usize,
270 condition: String,
272 offset: usize,
274 },
275
276 #[error("validation failed with {error_count} issue(s); first issue: {first_message}")]
278 ValidationFailed {
279 error_count: usize,
281 first_message: String,
283 },
284
285 #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
294 SegmentTooLong {
295 offset: usize,
297 limit: usize,
299 },
300
301 #[error("no handler registered for message type {message_type}")]
307 UnexpectedMessageType {
308 message_type: String,
310 },
311
312 #[error("interchange too large: count {count} exceeds u32::MAX")]
319 InterchangeTooLarge {
320 count: u64,
322 },
323
324 #[error("invalid event sequence: {message}")]
332 InvalidEventSequence {
333 message: &'static str,
335 },
336}
337
338
339
340impl From<std::io::Error> for EdifactError {
341 fn from(e: std::io::Error) -> Self {
342 Self::Io(IoError(e))
343 }
344}
345
346impl EdifactError {
347 #[must_use]
349 pub const fn stable_code(&self) -> &'static str {
350 match self {
351 Self::UnexpectedEof { .. } => "E001",
352 Self::InvalidDelimiter { .. } => "E002",
353 Self::InvalidText { .. } => "E003",
354 Self::MessageCountMismatch { .. } => "E004",
355 Self::SegmentCountMismatch { .. } => "E005",
356 Self::InvalidSegmentTag(_) => "E006",
357 Self::InvalidUna => "E007",
358 Self::MissingRequiredElement { .. } => "E008",
359 Self::InvalidUtf8 => "E009",
360 Self::Io(_) => "E010",
361 Self::InvalidSegmentForMessage { .. } => "E011",
362 Self::InvalidElementCount { .. } => "E012",
363 Self::InvalidComponentCount { .. } => "E013",
364 Self::InvalidCodeValue { .. } => "E014",
365 Self::MissingSegment { .. } => "E015",
366 Self::QualifierMismatch { .. } => "E016",
367 Self::ConditionalRequirementNotMet { .. } => "E017",
368 Self::ValidationFailed { .. } => "E018",
369 Self::InvalidReleaseSequence { .. } => "E019",
370 Self::SegmentTooLong { .. } => "E020",
371 Self::MissingRequiredComponent { .. } => "E021",
372 Self::UnexpectedMessageType { .. } => "E022",
373 Self::InterchangeTooLarge { .. } => "E023",
374 Self::InvalidEventSequence { .. } => "E024",
375 }
376 }
377
378 #[must_use]
380 pub fn recovery_hint(&self) -> Option<&'static str> {
381 match self {
382 Self::UnexpectedEof { .. } => {
383 Some("Ensure every segment ends with the configured segment terminator")
384 }
385 Self::InvalidDelimiter { .. } => {
386 Some("Check UNA service string advice and delimiter bytes in the payload")
387 }
388 Self::InvalidText { .. } => {
389 Some("Input must be valid UTF-8 text for segment and element values")
390 }
391 Self::InvalidReleaseSequence { .. } => {
392 Some("Release character must escape one following byte; trailing '?' is invalid")
393 }
394 Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
395 Self::InvalidUna => {
396 Some("UNA must be exactly 9 bytes: 'UNA' followed by 6 distinct, non-whitespace service characters")
397 }
398 Self::MissingRequiredElement { .. } => {
399 Some("Provide all mandatory elements for the segment per directory rules")
400 }
401 Self::MissingRequiredComponent { .. } => {
402 Some("Provide all mandatory components for the composite element per directory rules")
403 }
404 Self::InvalidSegmentForMessage { .. } => {
405 Some("Remove unsupported segment or switch to the correct message type")
406 }
407 Self::InvalidElementCount { .. } => {
408 Some("Adjust the segment element count to the allowed min/max range")
409 }
410 Self::InvalidComponentCount { .. } => {
411 Some("Fix composite element arity to match the expected component count")
412 }
413 Self::InvalidCodeValue { .. } => {
414 Some("Use a value from the referenced code list for this element")
415 }
416 Self::MissingSegment { .. } => {
417 Some("Insert the required segment at the expected position")
418 }
419 Self::QualifierMismatch { .. } => {
420 Some("Set the segment qualifier to the expected value")
421 }
422 Self::ConditionalRequirementNotMet { .. } => {
423 Some("When the condition is met, include the conditionally required element")
424 }
425 Self::SegmentTooLong { limit, .. } => {
426 let _ = limit; Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
428 }
429 Self::InvalidEventSequence { .. } => {
430 Some("Emit StartSegment before Element, and Element before ComponentElement")
431 }
432 Self::ValidationFailed { .. }
433 | Self::MessageCountMismatch { .. }
434 | Self::SegmentCountMismatch { .. }
435 | Self::UnexpectedMessageType { .. }
436 | Self::InterchangeTooLarge { .. }
437 | Self::InvalidUtf8
438 | Self::Io(_) => None,
439 }
440 }
441}
442
443#[cfg(feature = "diagnostics")]
444#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
445impl miette::Diagnostic for EdifactError {
446 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
447 Some(Box::new(self.stable_code()))
448 }
449
450 fn severity(&self) -> Option<miette::Severity> {
451 match self {
452 Self::InvalidCodeValue { .. }
453 | Self::InvalidComponentCount { .. }
454 | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
455 _ => Some(miette::Severity::Error),
456 }
457 }
458
459 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
460 match self {
461 Self::InvalidUna => Some(Box::new(
463 "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
464 )),
465 Self::InvalidUtf8 => Some(Box::new(
466 "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
467 )),
468 Self::UnexpectedEof { offset } => Some(Box::new(format!(
470 "Check that all segments are terminated with the segment terminator (usually '). \
471 Reached end at offset {offset}",
472 ))),
473 Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
474 "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
475 Check UNA configuration",
476 ))),
477 Self::InvalidText { offset } => Some(Box::new(format!(
478 "The byte sequence at offset {offset} contains invalid UTF-8. \
479 Ensure input is valid UTF-8",
480 ))),
481 Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
482 "Release character at offset {offset} is dangling. \
483 Ensure '?' is followed by an escaped byte",
484 ))),
485 Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
486 "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
487 Check the UNZ message count",
488 ))),
489 Self::SegmentCountMismatch { expected, actual, message_ref } => Some(Box::new(format!(
490 "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
491 Check the UNT segment count",
492 ))),
493 Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
494 "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
495 ))),
496 Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
497 "Segment {tag} requires element at index {element_index}",
498 ))),
499 Self::MissingRequiredComponent { tag, element_index, component_index } => {
500 Some(Box::new(format!(
501 "Segment {tag} element {element_index} requires component at index {component_index}",
502 )))
503 }
504 Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
505 Self::InvalidSegmentForMessage { tag, message_type, .. } => Some(Box::new(format!(
506 "Segment {tag} should not appear in a {message_type} message. \
507 Check the directory definition",
508 ))),
509 Self::InvalidElementCount { tag, min, max, actual, .. } => Some(Box::new(format!(
510 "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
511 Check segment structure",
512 ))),
513 Self::InvalidComponentCount { tag, element_index, expected, actual, .. } => {
514 Some(Box::new(format!(
515 "In segment {tag}, element {element_index} should have {expected} components \
516 but has {actual}. Check element structure",
517 )))
518 }
519 Self::InvalidCodeValue { tag, element_index, value, code_list, .. } => {
520 Some(Box::new(format!(
521 "Value '{value}' in segment {tag} element {element_index} is not in the \
522 {code_list} code list. Check the directory for valid codes",
523 )))
524 }
525 Self::MissingSegment { tag, expected_position } => Some(Box::new(format!(
526 "Segment {tag} is required at position {expected_position} but is missing. \
527 Add this segment to the message",
528 ))),
529 Self::QualifierMismatch { tag, actual, expected, .. } => Some(Box::new(format!(
530 "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
531 Check the segment's first component",
532 ))),
533 Self::ConditionalRequirementNotMet { tag, element_index, condition, .. } => {
534 Some(Box::new(format!(
535 "In segment {tag}, element {element_index} is conditionally required when: \
536 {condition}. Check if the condition is met",
537 )))
538 }
539 Self::ValidationFailed { error_count, first_message } => Some(Box::new(format!(
540 "Validation found {error_count} issue(s). Start by fixing: {first_message}",
541 ))),
542 Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
543 "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
544 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
545 or verify the input for a missing segment terminator",
546 ))),
547 Self::UnexpectedMessageType { message_type } => Some(Box::new(format!(
548 "No handler was registered for message type '{message_type}'. \
549 Register a handler with MessageDispatch::on(\"{message_type}\", ...)",
550 ))),
551 Self::InterchangeTooLarge { count } => Some(Box::new(format!(
552 "Interchange contains {count} items which exceeds the u32::MAX limit. \
553 This is an extremely unusual input; verify the message is not corrupted.",
554 ))),
555 Self::InvalidEventSequence { message } => Some(Box::new(format!(
556 "Event sequence violation: {message}. \
557 Check that StartSegment is emitted before Element, and Element before ComponentElement.",
558 ))),
559 }
560 }
561}
562
563#[non_exhaustive]
570#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
571pub enum ValidationSeverity {
572 Critical,
574 Error,
576 Warning,
578 Info,
580}
581
582impl std::fmt::Display for ValidationSeverity {
583 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584 match self {
585 Self::Critical => f.write_str("critical"),
586 Self::Error => f.write_str("error"),
587 Self::Warning => f.write_str("warning"),
588 Self::Info => f.write_str("info"),
589 }
590 }
591}
592
593#[derive(Debug, Clone, PartialEq)]
595pub struct ValidationIssue {
596 pub error_code: Option<&'static str>,
598 pub severity: ValidationSeverity,
600 pub message: String,
602 pub offset: Option<usize>,
604 pub segment_tag: Option<String>,
606 pub rule_id: Option<String>,
608 pub element_index: Option<u8>,
613 pub component_index: Option<u8>,
618 pub suggestion: Option<String>,
620}
621
622impl ValidationIssue {
623 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
625 Self {
626 error_code: None,
627 severity,
628 message: message.into(),
629 offset: None,
630 segment_tag: None,
631 rule_id: None,
632 element_index: None,
633 component_index: None,
634 suggestion: None,
635 }
636 }
637
638 pub fn with_error_code(mut self, code: &'static str) -> Self {
640 self.error_code = Some(code);
641 self
642 }
643
644 pub fn with_offset(mut self, offset: usize) -> Self {
646 self.offset = Some(offset);
647 self
648 }
649
650 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
652 self.segment_tag = Some(tag.into());
653 self
654 }
655
656 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
658 self.rule_id = Some(rule_id.into());
659 self
660 }
661
662 pub fn with_element_index(mut self, element_index: u8) -> Self {
664 self.element_index = Some(element_index);
665 self
666 }
667
668 pub fn with_component_index(mut self, component_index: u8) -> Self {
670 self.component_index = Some(component_index);
671 self
672 }
673
674 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
676 self.suggestion = Some(suggestion.into());
677 self
678 }
679
680 #[must_use]
682 pub fn severity_label(&self) -> &'static str {
683 match self.severity {
684 ValidationSeverity::Critical => "CRITICAL",
685 ValidationSeverity::Error => "ERROR",
686 ValidationSeverity::Warning => "WARNING",
687 ValidationSeverity::Info => "INFO",
688 }
689 }
690}
691
692impl std::fmt::Display for ValidationIssue {
693 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
694 write!(f, "[{}] {}", self.severity_label(), self.message)
695 }
696}
697
698impl std::error::Error for ValidationIssue {}
699
700#[derive(Debug, Clone, Default)]
704pub struct ValidationReport {
705 pub(crate) errors: Vec<ValidationIssue>,
707 pub(crate) warnings: Vec<ValidationIssue>,
709 pub(crate) infos: Vec<ValidationIssue>,
711}
712
713impl ValidationReport {
714 pub fn errors(&self) -> &[ValidationIssue] {
716 &self.errors
717 }
718
719 pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
721 &mut self.errors
722 }
723
724 pub fn warnings(&self) -> &[ValidationIssue] {
726 &self.warnings
727 }
728
729 pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
731 &mut self.warnings
732 }
733
734 pub fn infos(&self) -> &[ValidationIssue] {
736 &self.infos
737 }
738
739 pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
741 &mut self.infos
742 }
743 pub fn add_error(&mut self, issue: ValidationIssue) {
745 self.errors.push(issue);
746 }
747
748 pub fn add_warning(&mut self, issue: ValidationIssue) {
750 self.warnings.push(issue);
751 }
752
753 pub fn add_info(&mut self, issue: ValidationIssue) {
755 self.infos.push(issue);
756 }
757
758 pub fn has_errors(&self) -> bool {
760 !self.errors().is_empty()
761 }
762
763 pub fn has_warnings(&self) -> bool {
765 !self.warnings().is_empty()
766 }
767
768 pub fn total_issues(&self) -> usize {
770 self.errors().len() + self.warnings().len() + self.infos().len()
771 }
772
773 pub fn is_valid(&self) -> bool {
775 self.errors().is_empty()
776 }
777
778 pub fn result(self) -> Result<Self, Self> {
784 if self.is_valid() {
785 Ok(self)
786 } else {
787 Err(self)
788 }
789 }
790
791 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
793 self.errors()
794 .iter()
795 .chain(self.warnings().iter())
796 .chain(self.infos().iter())
797 }
798
799 pub fn has_any_issues(&self) -> bool {
801 !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
802 }
803
804 pub fn issues_for_rule_id<'a>(
809 &'a self,
810 rule_id: &'a str,
811 ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
812 self.iter_issues()
813 .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
814 }
815
816 fn filter_report<F>(&self, pred: F) -> Self
818 where
819 F: Fn(&ValidationIssue) -> bool,
820 {
821 Self {
822 errors: self.errors().iter().filter(|i| pred(i)).cloned().collect(),
823 warnings: self.warnings().iter().filter(|i| pred(i)).cloned().collect(),
824 infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
825 }
826 }
827
828 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
830 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
831 }
832
833 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
835 self.filter_report(|issue| {
836 issue
837 .rule_id
838 .as_deref()
839 .is_some_and(|id| id.starts_with(prefix))
840 })
841 }
842
843 pub fn render_deterministic(&self) -> String {
845 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
846 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
847 refs.sort_by(|left, right| {
848 left.offset
849 .unwrap_or(usize::MAX)
850 .cmp(&right.offset.unwrap_or(usize::MAX))
851 .then_with(|| {
852 left.segment_tag
853 .as_deref()
854 .unwrap_or("")
855 .cmp(right.segment_tag.as_deref().unwrap_or(""))
856 })
857 .then_with(|| {
858 left.rule_id
859 .as_deref()
860 .unwrap_or("")
861 .cmp(right.rule_id.as_deref().unwrap_or(""))
862 })
863 .then_with(|| {
864 left.element_index
865 .unwrap_or(u8::MAX)
866 .cmp(&right.element_index.unwrap_or(u8::MAX))
867 })
868 .then_with(|| {
869 left.component_index
870 .unwrap_or(u8::MAX)
871 .cmp(&right.component_index.unwrap_or(u8::MAX))
872 })
873 .then_with(|| {
874 left.error_code
875 .unwrap_or("")
876 .cmp(right.error_code.unwrap_or(""))
877 })
878 .then_with(|| left.message.cmp(&right.message))
879 });
880 refs
881 }
882
883 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
884 use std::fmt::Write as _;
885 out.push_str(" - ");
886 out.push_str(&issue.message);
887 if let Some(code) = issue.error_code {
888 out.push_str(" [");
889 out.push_str(code);
890 out.push(']');
891 }
892 if let Some(seg) = &issue.segment_tag {
893 out.push_str(" [segment=");
894 out.push_str(seg);
895 out.push(']');
896 }
897 if let Some(rule_id) = &issue.rule_id {
898 out.push_str(" [rule=");
899 out.push_str(rule_id);
900 out.push(']');
901 }
902 if let Some(element_index) = issue.element_index {
903 write!(out, " [element={element_index}]").ok();
904 }
905 if let Some(component_index) = issue.component_index {
906 write!(out, " [component={component_index}]").ok();
907 }
908 if let Some(offset) = issue.offset {
909 write!(out, " [offset={offset}]").ok();
910 }
911 if let Some(suggestion) = &issue.suggestion {
912 out.push_str(" [hint=");
913 out.push_str(suggestion);
914 out.push(']');
915 }
916 }
917
918 use std::fmt::Write as _;
919 let mut out = String::from("Validation Report:");
920 let errors = sorted_refs(self.errors());
921 let warnings = sorted_refs(self.warnings());
922 let infos = sorted_refs(self.infos());
923
924 if !errors.is_empty() {
925 write!(out, "\n Errors ({})", errors.len()).ok();
926 for issue in &errors {
927 out.push('\n');
928 render_issue_line(&mut out, issue);
929 }
930 }
931 if !warnings.is_empty() {
932 write!(out, "\n Warnings ({})", warnings.len()).ok();
933 for issue in &warnings {
934 out.push('\n');
935 render_issue_line(&mut out, issue);
936 }
937 }
938 if !infos.is_empty() {
939 write!(out, "\n Info ({})", infos.len()).ok();
940 for issue in &infos {
941 out.push('\n');
942 render_issue_line(&mut out, issue);
943 }
944 }
945
946 out
947 }
948}
949
950#[cfg(feature = "diagnostics")]
951impl miette::Diagnostic for ValidationReport {
952 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
953 Some(Box::new("VALIDATION"))
954 }
955
956 fn severity(&self) -> Option<miette::Severity> {
957 if self.has_errors() {
958 Some(miette::Severity::Error)
959 } else if self.has_warnings() {
960 Some(miette::Severity::Warning)
961 } else {
962 Some(miette::Severity::Advice)
963 }
964 }
965
966 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
967 let msg = format!(
968 "Validation found {} error(s), {} warning(s), {} info(s)",
969 self.errors().len(),
970 self.warnings().len(),
971 self.infos().len()
972 );
973 Some(Box::new(msg))
974 }
975}
976
977impl std::fmt::Display for ValidationReport {
978 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
979 write!(f, "{}", self.render_deterministic())
980 }
981}
982
983impl std::error::Error for ValidationReport {}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn validation_report_collects_errors() {
991 let mut report = ValidationReport::default();
992 report.add_error(
993 ValidationIssue::new(ValidationSeverity::Error, "Test error")
994 .with_segment("BGM")
995 .with_offset(42),
996 );
997 report.add_warning(ValidationIssue::new(
998 ValidationSeverity::Warning,
999 "Test warning",
1000 ));
1001
1002 assert!(report.has_errors());
1003 assert!(report.has_warnings());
1004 assert_eq!(report.total_issues(), 2);
1005 assert!(!report.is_valid());
1006 }
1007
1008 #[test]
1009 fn validation_report_result_conversion() {
1010 let mut report = ValidationReport::default();
1011 report.add_error(ValidationIssue::new(
1012 ValidationSeverity::Error,
1013 "Critical issue",
1014 ));
1015
1016 let result = report.result();
1017 assert!(result.is_err());
1018 }
1019
1020 #[test]
1021 fn validation_report_passes_when_no_errors() {
1022 let mut report = ValidationReport::default();
1023 report.add_warning(ValidationIssue::new(
1024 ValidationSeverity::Warning,
1025 "Just a warning",
1026 ));
1027
1028 assert!(report.is_valid());
1029 assert!(report.result().is_ok());
1030 }
1031
1032 #[test]
1033 fn validation_issue_builder() {
1034 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1035 .with_error_code("E013")
1036 .with_offset(100)
1037 .with_segment("NAD")
1038 .with_rule_id("DEMO-P001")
1039 .with_element_index(1)
1040 .with_component_index(2)
1041 .with_suggestion("Check element count");
1042
1043 assert_eq!(issue.error_code, Some("E013"));
1044 assert_eq!(issue.message, "test message");
1045 assert_eq!(issue.offset, Some(100));
1046 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1047 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1048 assert_eq!(issue.element_index, Some(1));
1049 assert_eq!(issue.component_index, Some(2));
1050 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1051 }
1052
1053 #[test]
1054 fn validation_report_display() {
1055 let mut report = ValidationReport::default();
1056 report.add_error(
1057 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1058 .with_error_code("E011")
1059 .with_offset(8),
1060 );
1061 report.add_warning(ValidationIssue::new(
1062 ValidationSeverity::Warning,
1063 "Warning 1",
1064 ));
1065 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1066
1067 let display_str = format!("{}", report);
1068 assert!(display_str.contains("Errors (1)"));
1069 assert!(display_str.contains("Warnings (1)"));
1070 assert!(display_str.contains("Info (1)"));
1071 assert!(display_str.contains("[E011]"));
1072 }
1073
1074 #[test]
1075 fn validation_report_render_is_deterministic() {
1076 let mut report = ValidationReport::default();
1077 report.add_error(
1078 ValidationIssue::new(ValidationSeverity::Error, "later")
1079 .with_segment("BGM")
1080 .with_offset(20),
1081 );
1082 report.add_error(
1083 ValidationIssue::new(ValidationSeverity::Error, "earlier")
1084 .with_segment("UNH")
1085 .with_offset(1),
1086 );
1087
1088 let rendered = report.render_deterministic();
1089 let first = rendered.find("earlier").expect("missing first issue");
1090 let second = rendered.find("later").expect("missing second issue");
1091 assert!(first < second, "expected deterministic sort by offset");
1092 }
1093
1094 #[test]
1095 fn recovery_hint_exists_for_common_malformed_cases() {
1096 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1097 assert!(err.recovery_hint().is_some());
1098
1099 let err = EdifactError::InvalidCodeValue {
1100 tag: "BGM".to_owned(),
1101 element_index: 0,
1102 value: "X".to_owned(),
1103 code_list: "1001".to_owned(),
1104 offset: 0,
1105 suggestion: None,
1106 };
1107 assert!(err.recovery_hint().is_some());
1108 }
1109
1110 #[test]
1111 fn validation_report_can_filter_by_rule_id() {
1112 let mut report = ValidationReport::default();
1113 report.add_error(
1114 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1115 .with_rule_id("ORDERS-P001"),
1116 );
1117 report.add_warning(
1118 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1119 .with_rule_id("INVOIC-P001"),
1120 );
1121 report.add_info(
1122 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1123 .with_rule_id("ORDERS-P002"),
1124 );
1125
1126 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1127 assert_eq!(only_orders_block.errors().len(), 1);
1128 assert!(only_orders_block.warnings().is_empty());
1129 assert!(only_orders_block.infos().is_empty());
1130
1131 let orders_family = report.filter_by_rule_prefix("ORDERS-");
1132 assert_eq!(orders_family.total_issues(), 2);
1133 assert!(orders_family.has_errors());
1134 assert!(!orders_family.has_warnings());
1135
1136 let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1137 assert_eq!(exact.len(), 1);
1138 assert_eq!(exact[0].message, "invoic policy warning");
1139 }
1140}