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