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
522impl std::fmt::Display for ValidationSeverity {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 match self {
525 Self::Critical => f.write_str("critical"),
526 Self::Error => f.write_str("error"),
527 Self::Warning => f.write_str("warning"),
528 Self::Info => f.write_str("info"),
529 }
530 }
531}
532
533#[derive(Debug, Clone, PartialEq)]
535pub struct ValidationIssue {
536 pub error_code: Option<&'static str>,
538 pub severity: ValidationSeverity,
540 pub message: String,
542 pub offset: Option<usize>,
544 pub segment_tag: Option<String>,
546 pub rule_id: Option<String>,
548 pub element_index: Option<u8>,
553 pub component_index: Option<u8>,
558 pub suggestion: Option<String>,
560}
561
562impl ValidationIssue {
563 pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
565 Self {
566 error_code: None,
567 severity,
568 message: message.into(),
569 offset: None,
570 segment_tag: None,
571 rule_id: None,
572 element_index: None,
573 component_index: None,
574 suggestion: None,
575 }
576 }
577
578 pub fn with_error_code(mut self, code: &'static str) -> Self {
580 self.error_code = Some(code);
581 self
582 }
583
584 pub fn with_offset(mut self, offset: usize) -> Self {
586 self.offset = Some(offset);
587 self
588 }
589
590 pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
592 self.segment_tag = Some(tag.into());
593 self
594 }
595
596 pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
598 self.rule_id = Some(rule_id.into());
599 self
600 }
601
602 pub fn with_element_index(mut self, element_index: u8) -> Self {
604 self.element_index = Some(element_index);
605 self
606 }
607
608 pub fn with_component_index(mut self, component_index: u8) -> Self {
610 self.component_index = Some(component_index);
611 self
612 }
613
614 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
616 self.suggestion = Some(suggestion.into());
617 self
618 }
619
620 #[must_use]
622 pub fn severity_label(&self) -> &'static str {
623 match self.severity {
624 ValidationSeverity::Critical => "CRITICAL",
625 ValidationSeverity::Error => "ERROR",
626 ValidationSeverity::Warning => "WARNING",
627 ValidationSeverity::Info => "INFO",
628 }
629 }
630}
631
632impl std::fmt::Display for ValidationIssue {
633 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634 write!(f, "[{}] {}", self.severity_label(), self.message)
635 }
636}
637
638impl std::error::Error for ValidationIssue {}
639
640#[derive(Debug, Clone, Default)]
644pub struct ValidationReport {
645 pub errors: Vec<ValidationIssue>,
647 pub warnings: Vec<ValidationIssue>,
649 pub infos: Vec<ValidationIssue>,
651}
652
653impl ValidationReport {
654 pub fn add_error(&mut self, issue: ValidationIssue) {
656 self.errors.push(issue);
657 }
658
659 pub fn add_warning(&mut self, issue: ValidationIssue) {
661 self.warnings.push(issue);
662 }
663
664 pub fn add_info(&mut self, issue: ValidationIssue) {
666 self.infos.push(issue);
667 }
668
669 pub fn has_errors(&self) -> bool {
671 !self.errors.is_empty()
672 }
673
674 pub fn has_warnings(&self) -> bool {
676 !self.warnings.is_empty()
677 }
678
679 pub fn total_issues(&self) -> usize {
681 self.errors.len() + self.warnings.len() + self.infos.len()
682 }
683
684 pub fn is_valid(&self) -> bool {
686 self.errors.is_empty()
687 }
688
689 pub fn result(self) -> Result<Self, Self> {
695 if self.is_valid() {
696 Ok(self)
697 } else {
698 Err(self)
699 }
700 }
701
702 pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
704 self.errors
705 .iter()
706 .chain(self.warnings.iter())
707 .chain(self.infos.iter())
708 }
709
710 pub fn has_any_issues(&self) -> bool {
712 !self.errors.is_empty() || !self.warnings.is_empty() || !self.infos.is_empty()
713 }
714
715 pub fn issues_for_rule_id<'a>(
720 &'a self,
721 rule_id: &'a str,
722 ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
723 self.iter_issues()
724 .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
725 }
726
727 fn filter_report<F>(&self, pred: F) -> Self
729 where
730 F: Fn(&ValidationIssue) -> bool,
731 {
732 Self {
733 errors: self.errors.iter().filter(|i| pred(i)).cloned().collect(),
734 warnings: self.warnings.iter().filter(|i| pred(i)).cloned().collect(),
735 infos: self.infos.iter().filter(|i| pred(i)).cloned().collect(),
736 }
737 }
738
739 pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
741 self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
742 }
743
744 pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
746 self.filter_report(|issue| {
747 issue
748 .rule_id
749 .as_deref()
750 .is_some_and(|id| id.starts_with(prefix))
751 })
752 }
753
754 pub fn render_deterministic(&self) -> String {
756 fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
757 let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
758 refs.sort_by(|left, right| {
759 left.offset
760 .unwrap_or(usize::MAX)
761 .cmp(&right.offset.unwrap_or(usize::MAX))
762 .then_with(|| {
763 left.segment_tag
764 .as_deref()
765 .unwrap_or("")
766 .cmp(right.segment_tag.as_deref().unwrap_or(""))
767 })
768 .then_with(|| {
769 left.rule_id
770 .as_deref()
771 .unwrap_or("")
772 .cmp(right.rule_id.as_deref().unwrap_or(""))
773 })
774 .then_with(|| {
775 left.element_index
776 .unwrap_or(u8::MAX)
777 .cmp(&right.element_index.unwrap_or(u8::MAX))
778 })
779 .then_with(|| {
780 left.component_index
781 .unwrap_or(u8::MAX)
782 .cmp(&right.component_index.unwrap_or(u8::MAX))
783 })
784 .then_with(|| {
785 left.error_code
786 .unwrap_or("")
787 .cmp(right.error_code.unwrap_or(""))
788 })
789 .then_with(|| left.message.cmp(&right.message))
790 });
791 refs
792 }
793
794 fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
795 use std::fmt::Write as _;
796 out.push_str(" - ");
797 out.push_str(&issue.message);
798 if let Some(code) = issue.error_code {
799 out.push_str(" [");
800 out.push_str(code);
801 out.push(']');
802 }
803 if let Some(seg) = &issue.segment_tag {
804 out.push_str(" [segment=");
805 out.push_str(seg);
806 out.push(']');
807 }
808 if let Some(rule_id) = &issue.rule_id {
809 out.push_str(" [rule=");
810 out.push_str(rule_id);
811 out.push(']');
812 }
813 if let Some(element_index) = issue.element_index {
814 write!(out, " [element={element_index}]").ok();
815 }
816 if let Some(component_index) = issue.component_index {
817 write!(out, " [component={component_index}]").ok();
818 }
819 if let Some(offset) = issue.offset {
820 write!(out, " [offset={offset}]").ok();
821 }
822 if let Some(suggestion) = &issue.suggestion {
823 out.push_str(" [hint=");
824 out.push_str(suggestion);
825 out.push(']');
826 }
827 }
828
829 use std::fmt::Write as _;
830 let mut out = String::from("Validation Report:");
831 let errors = sorted_refs(&self.errors);
832 let warnings = sorted_refs(&self.warnings);
833 let infos = sorted_refs(&self.infos);
834
835 if !errors.is_empty() {
836 write!(out, "\n Errors ({})", errors.len()).ok();
837 for issue in &errors {
838 out.push('\n');
839 render_issue_line(&mut out, issue);
840 }
841 }
842 if !warnings.is_empty() {
843 write!(out, "\n Warnings ({})", warnings.len()).ok();
844 for issue in &warnings {
845 out.push('\n');
846 render_issue_line(&mut out, issue);
847 }
848 }
849 if !infos.is_empty() {
850 write!(out, "\n Info ({})", infos.len()).ok();
851 for issue in &infos {
852 out.push('\n');
853 render_issue_line(&mut out, issue);
854 }
855 }
856
857 out
858 }
859}
860
861#[cfg(feature = "diagnostics")]
862impl miette::Diagnostic for ValidationReport {
863 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
864 Some(Box::new("VALIDATION"))
865 }
866
867 fn severity(&self) -> Option<miette::Severity> {
868 if self.has_errors() {
869 Some(miette::Severity::Error)
870 } else if self.has_warnings() {
871 Some(miette::Severity::Warning)
872 } else {
873 Some(miette::Severity::Advice)
874 }
875 }
876
877 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
878 let msg = format!(
879 "Validation found {} error(s), {} warning(s), {} info(s)",
880 self.errors.len(),
881 self.warnings.len(),
882 self.infos.len()
883 );
884 Some(Box::new(msg))
885 }
886}
887
888impl std::fmt::Display for ValidationReport {
889 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
890 write!(f, "{}", self.render_deterministic())
891 }
892}
893
894impl std::error::Error for ValidationReport {}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn validation_report_collects_errors() {
902 let mut report = ValidationReport::default();
903 report.add_error(
904 ValidationIssue::new(ValidationSeverity::Error, "Test error")
905 .with_segment("BGM")
906 .with_offset(42),
907 );
908 report.add_warning(ValidationIssue::new(
909 ValidationSeverity::Warning,
910 "Test warning",
911 ));
912
913 assert!(report.has_errors());
914 assert!(report.has_warnings());
915 assert_eq!(report.total_issues(), 2);
916 assert!(!report.is_valid());
917 }
918
919 #[test]
920 fn validation_report_result_conversion() {
921 let mut report = ValidationReport::default();
922 report.add_error(ValidationIssue::new(
923 ValidationSeverity::Error,
924 "Critical issue",
925 ));
926
927 let result = report.result();
928 assert!(result.is_err());
929 }
930
931 #[test]
932 fn validation_report_passes_when_no_errors() {
933 let mut report = ValidationReport::default();
934 report.add_warning(ValidationIssue::new(
935 ValidationSeverity::Warning,
936 "Just a warning",
937 ));
938
939 assert!(report.is_valid());
940 assert!(report.result().is_ok());
941 }
942
943 #[test]
944 fn validation_issue_builder() {
945 let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
946 .with_error_code("E013")
947 .with_offset(100)
948 .with_segment("NAD")
949 .with_rule_id("DEMO-P001")
950 .with_element_index(1)
951 .with_component_index(2)
952 .with_suggestion("Check element count");
953
954 assert_eq!(issue.error_code, Some("E013"));
955 assert_eq!(issue.message, "test message");
956 assert_eq!(issue.offset, Some(100));
957 assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
958 assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
959 assert_eq!(issue.element_index, Some(1));
960 assert_eq!(issue.component_index, Some(2));
961 assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
962 }
963
964 #[test]
965 fn validation_report_display() {
966 let mut report = ValidationReport::default();
967 report.add_error(
968 ValidationIssue::new(ValidationSeverity::Error, "Error 1")
969 .with_error_code("E011")
970 .with_offset(8),
971 );
972 report.add_warning(ValidationIssue::new(
973 ValidationSeverity::Warning,
974 "Warning 1",
975 ));
976 report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
977
978 let display_str = format!("{}", report);
979 assert!(display_str.contains("Errors (1)"));
980 assert!(display_str.contains("Warnings (1)"));
981 assert!(display_str.contains("Info (1)"));
982 assert!(display_str.contains("[E011]"));
983 }
984
985 #[test]
986 fn validation_report_render_is_deterministic() {
987 let mut report = ValidationReport::default();
988 report.add_error(
989 ValidationIssue::new(ValidationSeverity::Error, "later")
990 .with_segment("BGM")
991 .with_offset(20),
992 );
993 report.add_error(
994 ValidationIssue::new(ValidationSeverity::Error, "earlier")
995 .with_segment("UNH")
996 .with_offset(1),
997 );
998
999 let rendered = report.render_deterministic();
1000 let first = rendered.find("earlier").expect("missing first issue");
1001 let second = rendered.find("later").expect("missing second issue");
1002 assert!(first < second, "expected deterministic sort by offset");
1003 }
1004
1005 #[test]
1006 fn recovery_hint_exists_for_common_malformed_cases() {
1007 let err = EdifactError::InvalidReleaseSequence { offset: 10 };
1008 assert!(err.recovery_hint().is_some());
1009
1010 let err = EdifactError::InvalidCodeValue {
1011 tag: "BGM".to_owned(),
1012 element_index: 0,
1013 value: "X".to_owned(),
1014 code_list: "1001".to_owned(),
1015 offset: 0,
1016 suggestion: None,
1017 };
1018 assert!(err.recovery_hint().is_some());
1019 }
1020
1021 #[test]
1022 fn validation_report_can_filter_by_rule_id() {
1023 let mut report = ValidationReport::default();
1024 report.add_error(
1025 ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1026 .with_rule_id("ORDERS-P001"),
1027 );
1028 report.add_warning(
1029 ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1030 .with_rule_id("INVOIC-P001"),
1031 );
1032 report.add_info(
1033 ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1034 .with_rule_id("ORDERS-P002"),
1035 );
1036
1037 let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1038 assert_eq!(only_orders_block.errors.len(), 1);
1039 assert!(only_orders_block.warnings.is_empty());
1040 assert!(only_orders_block.infos.is_empty());
1041
1042 let orders_family = report.filter_by_rule_prefix("ORDERS-");
1043 assert_eq!(orders_family.total_issues(), 2);
1044 assert!(orders_family.has_errors());
1045 assert!(!orders_family.has_warnings());
1046
1047 let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1048 assert_eq!(exact.len(), 1);
1049 assert_eq!(exact[0].message, "invoic policy warning");
1050 }
1051}