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: must be exactly 9 bytes")]
120 InvalidUna,
121
122 #[error("missing required element {element_index} in segment {tag}")]
127 MissingRequiredElement {
128 tag: String,
130 element_index: usize,
132 },
133
134 #[error("serialized output contains invalid UTF-8")]
139 InvalidUtf8,
140
141 #[error(transparent)]
143 Io(#[from] IoError),
144
145 #[error("segment {tag} is not valid for message type {message_type}")]
150 InvalidSegmentForMessage {
151 tag: String,
153 message_type: String,
155 offset: usize,
157 },
158
159 #[error("segment {tag} has {actual} elements, expected between {min} and {max}")]
163 InvalidElementCount {
164 tag: String,
166 min: usize,
168 max: usize,
170 actual: usize,
172 offset: usize,
174 },
175
176 #[error("segment {tag} element {element_index} has {actual} components, expected {expected}")]
180 InvalidComponentCount {
181 tag: String,
183 element_index: usize,
185 expected: u8,
187 actual: u8,
189 offset: usize,
191 },
192
193 #[error(
198 "segment {tag} element {element_index}: '{value}' is not a valid code (code list {code_list})"
199 )]
200 InvalidCodeValue {
201 tag: String,
203 element_index: usize,
205 value: String,
207 code_list: String,
209 offset: usize,
211 suggestion: Option<&'static str>,
213 },
214
215 #[error("required segment {tag} is missing from message (position {expected_position})")]
219 MissingSegment {
220 tag: String,
222 expected_position: String,
224 },
225
226 #[error("segment {tag} has qualifier '{actual}', expected '{expected}'")]
230 QualifierMismatch {
231 tag: String,
233 actual: String,
235 expected: String,
237 offset: usize,
239 },
240
241 #[error("segment {tag} element {element_index}: conditional requirement not met ({condition})")]
246 ConditionalRequirementNotMet {
247 tag: String,
249 element_index: usize,
251 condition: String,
253 offset: usize,
255 },
256
257 #[error("validation failed with {error_count} issue(s); first issue: {first_message}")]
259 ValidationFailed {
260 error_count: usize,
262 first_message: String,
264 },
265
266 #[error("segment starting at byte offset {offset} exceeded maximum length of {limit} bytes")]
275 SegmentTooLong {
276 offset: usize,
278 limit: usize,
280 },
281}
282
283
284
285impl From<std::io::Error> for EdifactError {
286 fn from(e: std::io::Error) -> Self {
287 Self::Io(IoError(e))
288 }
289}
290
291impl EdifactError {
292 #[must_use]
294 pub const fn stable_code(&self) -> &'static str {
295 match self {
296 Self::UnexpectedEof { .. } => "E001",
297 Self::InvalidDelimiter { .. } => "E002",
298 Self::InvalidText { .. } => "E003",
299 Self::MessageCountMismatch { .. } => "E004",
300 Self::SegmentCountMismatch { .. } => "E005",
301 Self::InvalidSegmentTag(_) => "E006",
302 Self::InvalidUna => "E007",
303 Self::MissingRequiredElement { .. } => "E008",
304 Self::InvalidUtf8 => "E009",
305 Self::Io(_) => "E010",
306 Self::InvalidSegmentForMessage { .. } => "E011",
307 Self::InvalidElementCount { .. } => "E012",
308 Self::InvalidComponentCount { .. } => "E013",
309 Self::InvalidCodeValue { .. } => "E014",
310 Self::MissingSegment { .. } => "E015",
311 Self::QualifierMismatch { .. } => "E016",
312 Self::ConditionalRequirementNotMet { .. } => "E017",
313 Self::ValidationFailed { .. } => "E018",
314 Self::InvalidReleaseSequence { .. } => "E019",
315 Self::SegmentTooLong { .. } => "E020",
316 }
317 }
318
319 #[must_use]
321 pub fn recovery_hint(&self) -> Option<&'static str> {
322 match self {
323 Self::UnexpectedEof { .. } => {
324 Some("Ensure every segment ends with the configured segment terminator")
325 }
326 Self::InvalidDelimiter { .. } => {
327 Some("Check UNA service string advice and delimiter bytes in the payload")
328 }
329 Self::InvalidText { .. } => {
330 Some("Input must be valid UTF-8 text for segment and element values")
331 }
332 Self::InvalidReleaseSequence { .. } => {
333 Some("Release character must escape one following byte; trailing '?' is invalid")
334 }
335 Self::InvalidSegmentTag(_) => Some("Segment tags must be 3 ASCII uppercase letters"),
336 Self::InvalidUna => {
337 Some("UNA must be exactly 9 bytes: 'UNA' followed by 6 service characters")
338 }
339 Self::MissingRequiredElement { .. } => {
340 Some("Provide all mandatory elements for the segment per directory rules")
341 }
342 Self::InvalidSegmentForMessage { .. } => {
343 Some("Remove unsupported segment or switch to the correct message type")
344 }
345 Self::InvalidElementCount { .. } => {
346 Some("Adjust the segment element count to the allowed min/max range")
347 }
348 Self::InvalidComponentCount { .. } => {
349 Some("Fix composite element arity to match the expected component count")
350 }
351 Self::InvalidCodeValue { .. } => {
352 Some("Use a value from the referenced code list for this element")
353 }
354 Self::MissingSegment { .. } => {
355 Some("Insert the required segment at the expected position")
356 }
357 Self::QualifierMismatch { .. } => {
358 Some("Set the segment qualifier to the expected value")
359 }
360 Self::ConditionalRequirementNotMet { .. } => {
361 Some("When the condition is met, include the conditionally required element")
362 }
363 Self::SegmentTooLong { limit, .. } => {
364 let _ = limit; Some("Increase max_segment_bytes in ReaderConfig or reject the input as malformed")
366 }
367 Self::ValidationFailed { .. }
368 | Self::MessageCountMismatch { .. }
369 | Self::SegmentCountMismatch { .. }
370 | Self::InvalidUtf8
371 | Self::Io(_) => None,
372 }
373 }
374}
375
376#[cfg(feature = "diagnostics")]
377#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))]
378impl miette::Diagnostic for EdifactError {
379 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
380 Some(Box::new(self.stable_code()))
381 }
382
383 fn severity(&self) -> Option<miette::Severity> {
384 match self {
385 Self::InvalidCodeValue { .. }
386 | Self::InvalidComponentCount { .. }
387 | Self::QualifierMismatch { .. } => Some(miette::Severity::Warning),
388 _ => Some(miette::Severity::Error),
389 }
390 }
391
392 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
393 match self {
394 Self::InvalidUna => Some(Box::new(
396 "UNA segment must be exactly 9 bytes: 'UNA' + 6 service characters. See EDIFACT spec",
397 )),
398 Self::InvalidUtf8 => Some(Box::new(
399 "Internal error: serialized output contains invalid UTF-8. Please report this as a bug",
400 )),
401 Self::UnexpectedEof { offset } => Some(Box::new(format!(
403 "Check that all segments are terminated with the segment terminator (usually '). \
404 Reached end at offset {offset}",
405 ))),
406 Self::InvalidDelimiter { byte, offset } => Some(Box::new(format!(
407 "The byte 0x{byte:02X} at offset {offset} is not a valid delimiter. \
408 Check UNA configuration",
409 ))),
410 Self::InvalidText { offset } => Some(Box::new(format!(
411 "The byte sequence at offset {offset} contains invalid UTF-8. \
412 Ensure input is valid UTF-8",
413 ))),
414 Self::InvalidReleaseSequence { offset } => Some(Box::new(format!(
415 "Release character at offset {offset} is dangling. \
416 Ensure '?' is followed by an escaped byte",
417 ))),
418 Self::MessageCountMismatch { expected, actual } => Some(Box::new(format!(
419 "UNZ declares {expected} message(s) but {actual} UNH/UNT pair(s) were found. \
420 Check the UNZ message count",
421 ))),
422 Self::SegmentCountMismatch { expected, actual, message_ref } => Some(Box::new(format!(
423 "UNT for message {message_ref} declares {expected} segment(s) but {actual} were found. \
424 Check the UNT segment count",
425 ))),
426 Self::InvalidSegmentTag(tag) => Some(Box::new(format!(
427 "Segment tag '{tag}' must be exactly 3 ASCII uppercase letters",
428 ))),
429 Self::MissingRequiredElement { tag, element_index } => Some(Box::new(format!(
430 "Segment {tag} requires element at index {element_index}",
431 ))),
432 Self::Io(e) => Some(Box::new(format!("I/O error: {e}"))),
433 Self::InvalidSegmentForMessage { tag, message_type, .. } => Some(Box::new(format!(
434 "Segment {tag} should not appear in a {message_type} message. \
435 Check the directory definition",
436 ))),
437 Self::InvalidElementCount { tag, min, max, actual, .. } => Some(Box::new(format!(
438 "Segment {tag} should have between {min} and {max} elements, but has {actual}. \
439 Check segment structure",
440 ))),
441 Self::InvalidComponentCount { tag, element_index, expected, actual, .. } => {
442 Some(Box::new(format!(
443 "In segment {tag}, element {element_index} should have {expected} components \
444 but has {actual}. Check element structure",
445 )))
446 }
447 Self::InvalidCodeValue { tag, element_index, value, code_list, .. } => {
448 Some(Box::new(format!(
449 "Value '{value}' in segment {tag} element {element_index} is not in the \
450 {code_list} code list. Check the directory for valid codes",
451 )))
452 }
453 Self::MissingSegment { tag, expected_position } => Some(Box::new(format!(
454 "Segment {tag} is required at position {expected_position} but is missing. \
455 Add this segment to the message",
456 ))),
457 Self::QualifierMismatch { tag, actual, expected, .. } => Some(Box::new(format!(
458 "Segment {tag} has qualifier '{actual}' but expected '{expected}'. \
459 Check the segment's first component",
460 ))),
461 Self::ConditionalRequirementNotMet { tag, element_index, condition, .. } => {
462 Some(Box::new(format!(
463 "In segment {tag}, element {element_index} is conditionally required when: \
464 {condition}. Check if the condition is met",
465 )))
466 }
467 Self::ValidationFailed { error_count, first_message } => Some(Box::new(format!(
468 "Validation found {error_count} issue(s). Start by fixing: {first_message}",
469 ))),
470 Self::SegmentTooLong { offset, limit } => Some(Box::new(format!(
471 "Segment starting at byte offset {offset} exceeds the {limit}-byte limit. \
472 Use ReaderConfig::max_segment_bytes to adjust the limit if needed, \
473 or verify the input for a missing segment terminator",
474 ))),
475 }
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
483pub enum ValidationSeverity {
484 Critical,
486 Error,
488 Warning,
490 Info,
492}
493
494#[derive(Debug, Clone, PartialEq)]
496pub struct ValidationIssue {
497 pub error_code: Option<&'static str>,
499 pub severity: ValidationSeverity,
501 pub message: String,
503 pub offset: Option<usize>,
505 pub segment_tag: Option<String>,
507 pub rule_id: Option<String>,
509 pub element_index: Option<u8>,
511 pub component_index: Option<u8>,
513 pub suggestion: Option<String>,
515}
516
517impl ValidationIssue {
518 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
520 Self {
521 error_code: None,
522 severity,
523 message: message.into(),
524 offset: None,
525 segment_tag: None,
526 rule_id: None,
527 element_index: None,
528 component_index: None,
529 suggestion: None,
530 }
531 }
532
533 pub fn with_error_code(mut self, code: &'static str) -> Self {
535 self.error_code = Some(code);
536 self
537 }
538
539 pub fn with_offset(mut self, offset: usize) -> Self {
541 self.offset = Some(offset);
542 self
543 }
544
545 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
547 self.segment_tag = Some(tag.into());
548 self
549 }
550
551 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
553 self.rule_id = Some(rule_id.into());
554 self
555 }
556
557 pub fn with_element_index(mut self, element_index: u8) -> Self {
559 self.element_index = Some(element_index);
560 self
561 }
562
563 pub fn with_component_index(mut self, component_index: u8) -> Self {
565 self.component_index = Some(component_index);
566 self
567 }
568
569 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
571 self.suggestion = Some(suggestion.into());
572 self
573 }
574}
575
576#[derive(Debug, Clone, Default)]
580pub struct ValidationReport {
581 pub errors: Vec<ValidationIssue>,
583 pub warnings: Vec<ValidationIssue>,
585 pub infos: Vec<ValidationIssue>,
587}
588
589impl ValidationReport {
590 pub fn add_error(&mut self, issue: ValidationIssue) {
592 self.errors.push(issue);
593 }
594
595 pub fn add_warning(&mut self, issue: ValidationIssue) {
597 self.warnings.push(issue);
598 }
599
600 pub fn add_info(&mut self, issue: ValidationIssue) {
602 self.infos.push(issue);
603 }
604
605 pub fn has_errors(&self) -> bool {
607 !self.errors.is_empty()
608 }
609
610 pub fn has_warnings(&self) -> bool {
612 !self.warnings.is_empty()
613 }
614
615 pub fn total_issues(&self) -> usize {
617 self.errors.len() + self.warnings.len() + self.infos.len()
618 }
619
620 pub fn is_valid(&self) -> bool {
622 self.errors.is_empty()
623 }
624
625 pub fn result(self) -> Result<Self, Vec<ValidationIssue>> {
627 if self.is_valid() {
628 Ok(self)
629 } else {
630 Err(self.errors)
631 }
632 }
633
634 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
636 self.errors
637 .iter()
638 .chain(self.warnings.iter())
639 .chain(self.infos.iter())
640 }
641
642 pub fn issues(&self) -> impl Iterator<Item = &ValidationIssue> {
644 self.iter_issues()
645 }
646
647 pub fn has_any_issues(&self) -> bool {
649 !self.errors.is_empty() || !self.warnings.is_empty() || !self.infos.is_empty()
650 }
651
652 pub fn issues_for_rule_id<'a>(&'a self, rule_id: &str) -> Vec<&'a ValidationIssue> {
654 self.iter_issues()
655 .filter(|issue| issue.rule_id.as_deref() == Some(rule_id))
656 .collect()
657 }
658
659 fn filter_report<F>(&self, pred: F) -> Self
661 where
662 F: Fn(&ValidationIssue) -> bool,
663 {
664 Self {
665 errors: self.errors.iter().filter(|i| pred(i)).cloned().collect(),
666 warnings: self.warnings.iter().filter(|i| pred(i)).cloned().collect(),
667 infos: self.infos.iter().filter(|i| pred(i)).cloned().collect(),
668 }
669 }
670
671 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
673 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
674 }
675
676 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
678 self.filter_report(|issue| {
679 issue
680 .rule_id
681 .as_deref()
682 .is_some_and(|id| id.starts_with(prefix))
683 })
684 }
685
686 pub fn render_deterministic(&self) -> String {
688 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
689 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
690 refs.sort_by(|left, right| {
691 left.offset
692 .unwrap_or(usize::MAX)
693 .cmp(&right.offset.unwrap_or(usize::MAX))
694 .then_with(|| {
695 left.segment_tag
696 .as_deref()
697 .unwrap_or("")
698 .cmp(right.segment_tag.as_deref().unwrap_or(""))
699 })
700 .then_with(|| {
701 left.rule_id
702 .as_deref()
703 .unwrap_or("")
704 .cmp(right.rule_id.as_deref().unwrap_or(""))
705 })
706 .then_with(|| {
707 left.element_index
708 .unwrap_or(u8::MAX)
709 .cmp(&right.element_index.unwrap_or(u8::MAX))
710 })
711 .then_with(|| {
712 left.component_index
713 .unwrap_or(u8::MAX)
714 .cmp(&right.component_index.unwrap_or(u8::MAX))
715 })
716 .then_with(|| {
717 left.error_code
718 .unwrap_or("")
719 .cmp(right.error_code.unwrap_or(""))
720 })
721 .then_with(|| left.message.cmp(&right.message))
722 });
723 refs
724 }
725
726 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
727 use std::fmt::Write as _;
728 out.push_str(" - ");
729 out.push_str(&issue.message);
730 if let Some(code) = issue.error_code {
731 out.push_str(" [");
732 out.push_str(code);
733 out.push(']');
734 }
735 if let Some(seg) = &issue.segment_tag {
736 out.push_str(" [segment=");
737 out.push_str(seg);
738 out.push(']');
739 }
740 if let Some(rule_id) = &issue.rule_id {
741 out.push_str(" [rule=");
742 out.push_str(rule_id);
743 out.push(']');
744 }
745 if let Some(element_index) = issue.element_index {
746 write!(out, " [element={element_index}]").ok();
747 }
748 if let Some(component_index) = issue.component_index {
749 write!(out, " [component={component_index}]").ok();
750 }
751 if let Some(offset) = issue.offset {
752 write!(out, " [offset={offset}]").ok();
753 }
754 if let Some(suggestion) = &issue.suggestion {
755 out.push_str(" [hint=");
756 out.push_str(suggestion);
757 out.push(']');
758 }
759 }
760
761 use std::fmt::Write as _;
762 let mut out = String::from("Validation Report:");
763 let errors = sorted_refs(&self.errors);
764 let warnings = sorted_refs(&self.warnings);
765 let infos = sorted_refs(&self.infos);
766
767 if !errors.is_empty() {
768 write!(out, "\n Errors ({})", errors.len()).ok();
769 for issue in &errors {
770 out.push('\n');
771 render_issue_line(&mut out, issue);
772 }
773 }
774 if !warnings.is_empty() {
775 write!(out, "\n Warnings ({})", warnings.len()).ok();
776 for issue in &warnings {
777 out.push('\n');
778 render_issue_line(&mut out, issue);
779 }
780 }
781 if !infos.is_empty() {
782 write!(out, "\n Info ({})", infos.len()).ok();
783 for issue in &infos {
784 out.push('\n');
785 render_issue_line(&mut out, issue);
786 }
787 }
788
789 out
790 }
791}
792
793#[cfg(feature = "diagnostics")]
794impl miette::Diagnostic for ValidationReport {
795 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
796 Some(Box::new("VALIDATION"))
797 }
798
799 fn severity(&self) -> Option<miette::Severity> {
800 if self.has_errors() {
801 Some(miette::Severity::Error)
802 } else if self.has_warnings() {
803 Some(miette::Severity::Warning)
804 } else {
805 Some(miette::Severity::Advice)
806 }
807 }
808
809 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
810 let msg = format!(
811 "Validation found {} error(s), {} warning(s), {} info(s)",
812 self.errors.len(),
813 self.warnings.len(),
814 self.infos.len()
815 );
816 Some(Box::new(msg))
817 }
818}
819
820impl std::fmt::Display for ValidationReport {
821 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822 write!(f, "{}", self.render_deterministic())
823 }
824}
825
826impl std::error::Error for ValidationReport {}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 #[test]
833 fn validation_report_collects_errors() {
834 let mut report = ValidationReport::default();
835 report.add_error(
836 ValidationIssue::new(ValidationSeverity::Error, "Test error")
837 .with_segment("BGM")
838 .with_offset(42),
839 );
840 report.add_warning(ValidationIssue::new(
841 ValidationSeverity::Warning,
842 "Test warning",
843 ));
844
845 assert!(report.has_errors());
846 assert!(report.has_warnings());
847 assert_eq!(report.total_issues(), 2);
848 assert!(!report.is_valid());
849 }
850
851 #[test]
852 fn validation_report_result_conversion() {
853 let mut report = ValidationReport::default();
854 report.add_error(ValidationIssue::new(
855 ValidationSeverity::Error,
856 "Critical issue",
857 ));
858
859 let result = report.result();
860 assert!(result.is_err());
861 }
862
863 #[test]
864 fn validation_report_passes_when_no_errors() {
865 let mut report = ValidationReport::default();
866 report.add_warning(ValidationIssue::new(
867 ValidationSeverity::Warning,
868 "Just a warning",
869 ));
870
871 assert!(report.is_valid());
872 assert!(report.result().is_ok());
873 }
874
875 #[test]
876 fn validation_issue_builder() {
877 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
878 .with_error_code("E013")
879 .with_offset(100)
880 .with_segment("NAD")
881 .with_rule_id("DEMO-P001")
882 .with_element_index(1)
883 .with_component_index(2)
884 .with_suggestion("Check element count");
885
886 assert_eq!(issue.error_code, Some("E013"));
887 assert_eq!(issue.message, "test message");
888 assert_eq!(issue.offset, Some(100));
889 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
890 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
891 assert_eq!(issue.element_index, Some(1));
892 assert_eq!(issue.component_index, Some(2));
893 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
894 }
895
896 #[test]
897 fn validation_report_display() {
898 let mut report = ValidationReport::default();
899 report.add_error(
900 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
901 .with_error_code("E011")
902 .with_offset(8),
903 );
904 report.add_warning(ValidationIssue::new(
905 ValidationSeverity::Warning,
906 "Warning 1",
907 ));
908 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
909
910 let display_str = format!("{}", report);
911 assert!(display_str.contains("Errors (1)"));
912 assert!(display_str.contains("Warnings (1)"));
913 assert!(display_str.contains("Info (1)"));
914 assert!(display_str.contains("[E011]"));
915 }
916
917 #[test]
918 fn validation_report_render_is_deterministic() {
919 let mut report = ValidationReport::default();
920 report.add_error(
921 ValidationIssue::new(ValidationSeverity::Error, "later")
922 .with_segment("BGM")
923 .with_offset(20),
924 );
925 report.add_error(
926 ValidationIssue::new(ValidationSeverity::Error, "earlier")
927 .with_segment("UNH")
928 .with_offset(1),
929 );
930
931 let rendered = report.render_deterministic();
932 let first = rendered.find("earlier").expect("missing first issue");
933 let second = rendered.find("later").expect("missing second issue");
934 assert!(first < second, "expected deterministic sort by offset");
935 }
936
937 #[test]
938 fn recovery_hint_exists_for_common_malformed_cases() {
939 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
940 assert!(err.recovery_hint().is_some());
941
942 let err = EdifactError::InvalidCodeValue {
943 tag: "BGM".to_owned(),
944 element_index: 0,
945 value: "X".to_owned(),
946 code_list: "1001".to_owned(),
947 offset: 0,
948 suggestion: None,
949 };
950 assert!(err.recovery_hint().is_some());
951 }
952
953 #[test]
954 fn validation_report_can_filter_by_rule_id() {
955 let mut report = ValidationReport::default();
956 report.add_error(
957 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
958 .with_rule_id("ORDERS-P001"),
959 );
960 report.add_warning(
961 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
962 .with_rule_id("INVOIC-P001"),
963 );
964 report.add_info(
965 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
966 .with_rule_id("ORDERS-P002"),
967 );
968
969 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
970 assert_eq!(only_orders_block.errors.len(), 1);
971 assert!(only_orders_block.warnings.is_empty());
972 assert!(only_orders_block.infos.is_empty());
973
974 let orders_family = report.filter_by_rule_prefix("ORDERS-");
975 assert_eq!(orders_family.total_issues(), 2);
976 assert!(orders_family.has_errors());
977 assert!(!orders_family.has_warnings());
978
979 let exact = report.issues_for_rule_id("INVOIC-P001");
980 assert_eq!(exact.len(), 1);
981 assert_eq!(exact[0].message, "invoic policy warning");
982 }
983}