1pub mod context;
11pub mod pack;
12
13pub use context::{ValidationContext, ValidationContextBuilder};
14pub use pack::{ProfileRule, ProfileRulePack};
15
16use crate::{EdifactError, Segment, Span, ValidationIssue, ValidationReport, ValidationSeverity};
17use std::any::Any;
18
19#[derive(Clone, Copy)]
34pub struct ValidationRuleContext<'a> {
35 pub(super) metadata: Option<&'a (dyn Any + Send + Sync)>,
36 pub message_ref: Option<&'a str>,
38 pub message_type: Option<&'a str>,
45}
46
47impl<'a> ValidationRuleContext<'a> {
48 pub fn empty() -> Self {
50 Self {
51 metadata: None,
52 message_ref: None,
53 message_type: None,
54 }
55 }
56
57 pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
59 Self {
60 metadata: Some(value as &(dyn Any + Send + Sync)),
61 message_ref: None,
62 message_type: None,
63 }
64 }
65
66 pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
68 self.message_ref = Some(msg_ref);
69 self
70 }
71
72 pub fn with_message_type(mut self, message_type: &'a str) -> Self {
74 self.message_type = Some(message_type);
75 self
76 }
77
78 pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
81 self.metadata?.downcast_ref::<T>()
82 }
83
84 pub fn has_metadata(&self) -> bool {
86 self.metadata.is_some()
87 }
88}
89
90impl std::fmt::Debug for ValidationRuleContext<'_> {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("ValidationRuleContext")
93 .field("has_metadata", &self.metadata.is_some())
94 .field("message_ref", &self.message_ref)
95 .field("message_type", &self.message_type)
96 .finish()
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum ValidationLayer {
104 Envelope,
106 Structure,
108 CodeList,
110 Profile,
112}
113
114pub trait Validator: Send + Sync {
119 fn validate_batch(
121 &self,
122 segments: &[Segment<'_>],
123 report: &mut ValidationReport,
124 context: &ValidationRuleContext<'_>,
125 );
126
127 fn validate_group_batch(
138 &self,
139 _root: &crate::group::SegmentGroupIndexed<'_>,
140 _all_segments: &[Segment<'_>],
141 _report: &mut ValidationReport,
142 _context: &ValidationRuleContext<'_>,
143 ) {
144 }
145
146 fn has_group_rules(&self) -> bool {
154 false
155 }
156
157 fn set_message_type(&mut self, _message_type: Option<&str>) {}
159
160 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
172 None
173 }
174}
175
176pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
179where
180 F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
181{
182 for segment in segments {
183 if let Err(err) = f(segment) {
184 report_error(report, err);
185 }
186 }
187}
188
189pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
194 let issue = issue_from_error(err);
195 match issue.severity {
196 ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
197 ValidationSeverity::Warning => report.add_warning(issue),
198 ValidationSeverity::Info => report.add_info(issue),
199 }
200}
201
202pub struct EnvelopeValidator;
210
211impl Validator for EnvelopeValidator {
212 fn validate_batch(
213 &self,
214 segments: &[Segment<'_>],
215 report: &mut ValidationReport,
216 _ctx: &ValidationRuleContext<'_>,
217 ) {
218 for e in crate::envelope::validate_envelope_lenient(segments).errors {
223 report_error(report, e);
224 }
225 }
226
227 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
228 Some(Box::new(EnvelopeValidator))
229 }
230}
231
232pub struct SyntaxValidator;
272
273impl Validator for SyntaxValidator {
274 fn validate_batch(
275 &self,
276 segments: &[Segment<'_>],
277 report: &mut ValidationReport,
278 _context: &ValidationRuleContext<'_>,
279 ) {
280 for segment in segments {
281 if segment.elements.is_empty() {
287 report_error(
288 report,
289 EdifactError::SegmentWithoutDataElements {
290 tag: segment.tag().to_owned(),
291 span: segment.span,
292 },
293 );
294 }
295
296 if segment
300 .elements
301 .last()
302 .is_some_and(|element| element.repetitions().flatten().all(|(v, _)| v.is_empty()))
303 && segment.elements.len() > 1
304 {
305 report_error(
306 report,
307 EdifactError::TrailingSeparator {
308 tag: segment.tag().to_owned(),
309 element_index: None,
310 span: segment.span,
311 },
312 );
313 }
314
315 for (element_index, element) in segment.elements.iter().enumerate() {
316 if element.components.len() > 1
320 && element.components.last().is_some_and(|(v, _)| v.is_empty())
321 {
322 report_error(
323 report,
324 EdifactError::TrailingSeparator {
325 tag: segment.tag().to_owned(),
326 element_index: Some(element_index),
327 span: element.span,
328 },
329 );
330 }
331
332 for components in element.repetitions() {
333 for (component_index, (value, span)) in components.iter().enumerate() {
334 if !value.is_empty() && value.bytes().all(|b| b == b' ') {
338 report_error(
339 report,
340 EdifactError::BlankDataElementValue {
341 tag: segment.tag().to_owned(),
342 element_index,
343 component_index,
344 span: *span,
345 },
346 );
347 }
348 }
349 }
350 }
351 }
352 }
353
354 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
355 Some(Box::new(SyntaxValidator))
356 }
357}
358
359pub struct CharsetValidator {
397 charset: Option<crate::Charset>,
399}
400
401impl CharsetValidator {
402 #[must_use]
407 pub fn from_envelope() -> Self {
408 Self { charset: None }
409 }
410
411 #[must_use]
416 pub fn with_charset(charset: crate::Charset) -> Self {
417 Self {
418 charset: Some(charset),
419 }
420 }
421
422 fn resolve(&self, segments: &[Segment<'_>]) -> Option<crate::Charset> {
424 if self.charset.is_some() {
425 return self.charset;
426 }
427 let identifier = segments
428 .iter()
429 .find(|s| s.tag == "UNB")
430 .and_then(|unb| unb.component_str(0, 0))?;
431 crate::Charset::from_syntax_identifier(identifier).ok()
435 }
436}
437
438impl Validator for CharsetValidator {
439 fn validate_batch(
440 &self,
441 segments: &[Segment<'_>],
442 report: &mut ValidationReport,
443 _context: &ValidationRuleContext<'_>,
444 ) {
445 let Some(charset) = self.resolve(segments) else {
446 return;
447 };
448 if charset == crate::Charset::UnoY {
449 return; }
451 for segment in segments {
452 for (element_index, element) in segment.elements.iter().enumerate() {
453 for components in element.repetitions() {
454 for (component_index, (value, span)) in components.iter().enumerate() {
455 let Some((offset, character)) = charset.first_violation(value) else {
456 continue;
457 };
458 let mut issue = ValidationIssue::new(
459 ValidationSeverity::Error,
460 format!(
461 "character {character:?} is not in the {charset} character \
462 repertoire declared by UNB S001",
463 ),
464 )
465 .with_error_code(
466 EdifactError::CharacterNotInRepertoire {
467 charset: charset.syntax_identifier(),
468 character,
469 offset,
470 }
471 .stable_code(),
472 )
473 .with_segment(segment.tag())
474 .with_span(*span)
475 .with_suggestion(
476 "Transliterate the value, or declare a wider repertoire in UNB S001 \
477 (UNOC for Latin-1, UNOY for UTF-8)",
478 );
479 if let Ok(index) = u8::try_from(element_index) {
480 issue = issue.with_element_index(index);
481 }
482 if let Ok(index) = u8::try_from(component_index) {
483 issue = issue.with_component_index(index);
484 }
485 report.add_error(issue);
486 }
487 }
488 }
489 }
490 }
491
492 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
493 Some(Box::new(Self {
494 charset: self.charset,
495 }))
496 }
497}
498
499fn issue_from_error(err: EdifactError) -> ValidationIssue {
500 let code = err.stable_code();
501 let mut issue = ValidationIssue::new(crate::report::severity_for_error(&err), err.to_string())
502 .with_error_code(code);
503 let default_hint = err.recovery_hint();
504
505 match err {
506 EdifactError::InvalidSegmentForMessage { tag, span, .. } => {
507 issue = issue.with_segment(tag).with_span(span);
508 }
509 EdifactError::InvalidElementCount { tag, span, .. } => {
510 issue = issue.with_segment(tag).with_span(span);
511 }
512 EdifactError::InvalidComponentCount {
513 tag,
514 element_index,
515 span,
516 ..
517 } => {
518 issue = issue
519 .with_segment(tag)
520 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
521 .with_span(span);
522 }
523 EdifactError::InvalidCodeValue {
524 tag,
525 element_index,
526 span,
527 suggestion,
528 ..
529 } => {
530 issue = issue
531 .with_segment(tag)
532 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
533 .with_span(span);
534 if let Some(s) = suggestion {
535 issue = issue.with_suggestion(s);
536 }
537 }
538 EdifactError::MissingSegment { tag, .. } => {
539 issue = issue.with_segment(tag);
540 }
541 EdifactError::QualifierMismatch { tag, span, .. } => {
542 issue = issue
543 .with_segment(tag)
544 .with_element_index(0)
545 .with_span(span);
546 }
547 EdifactError::ConditionalRequirementNotMet {
548 tag,
549 element_index,
550 span,
551 ..
552 } => {
553 issue = issue
554 .with_segment(tag)
555 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
556 .with_span(span);
557 }
558 EdifactError::DuplicateReference { tag, span, .. }
559 | EdifactError::PackageNotSupported { tag, span }
560 | EdifactError::SegmentWithoutDataElements { tag, span } => {
561 issue = issue.with_segment(tag).with_span(span);
562 }
563 EdifactError::EmptyMessage { span, .. } => {
564 issue = issue.with_segment("UNH").with_span(span);
565 }
566 EdifactError::GroupsAndMessagesMixed { span } => {
567 issue = issue.with_segment("UNH").with_span(span);
568 }
569 EdifactError::TrailingSeparator {
570 tag,
571 element_index,
572 span,
573 } => {
574 issue = issue.with_segment(tag).with_span(span);
575 if let Some(index) = element_index.and_then(|i| u8::try_from(i).ok()) {
576 issue = issue.with_element_index(index);
577 }
578 }
579 EdifactError::SegmentCountMismatch {
582 span, message_ref, ..
583 } => {
584 issue = issue
585 .with_segment("UNT")
586 .with_span(span)
587 .with_message_ref(message_ref);
588 }
589 EdifactError::TooManyRepetitions {
590 tag,
591 element_index,
592 span,
593 ..
594 } => {
595 issue = issue.with_segment(tag).with_span(span);
596 if let Ok(index) = u8::try_from(element_index) {
597 issue = issue.with_element_index(index);
598 }
599 }
600 EdifactError::InvalidCharacterType {
601 tag,
602 element_index,
603 component_index,
604 span,
605 ..
606 }
607 | EdifactError::DataElementTooLong {
608 tag,
609 element_index,
610 component_index,
611 span,
612 ..
613 }
614 | EdifactError::DataElementTooShort {
615 tag,
616 element_index,
617 component_index,
618 span,
619 ..
620 }
621 | EdifactError::InsignificantCharacters {
622 tag,
623 element_index,
624 component_index,
625 span,
626 ..
627 }
628 | EdifactError::BlankDataElementValue {
629 tag,
630 element_index,
631 component_index,
632 span,
633 } => {
634 issue = issue.with_segment(tag).with_span(span);
635 if let Ok(index) = u8::try_from(element_index) {
636 issue = issue.with_element_index(index);
637 }
638 if let Ok(index) = u8::try_from(component_index) {
639 issue = issue.with_component_index(index);
640 }
641 }
642 EdifactError::MissingRequiredElement { tag, element_index } => {
643 issue = issue.with_segment(tag);
644 if let Ok(idx) = u8::try_from(element_index) {
645 issue = issue.with_element_index(idx);
646 }
647 }
648 EdifactError::MissingRequiredComponent {
649 tag,
650 element_index,
651 component_index,
652 } => {
653 issue = issue.with_segment(tag);
654 if let Ok(ei) = u8::try_from(element_index) {
655 issue = issue.with_element_index(ei);
656 }
657 if let Ok(ci) = u8::try_from(component_index) {
658 issue = issue.with_component_index(ci);
659 }
660 }
661 EdifactError::InvalidReleaseSequence { offset }
664 | EdifactError::InvalidDelimiter { offset, .. }
665 | EdifactError::InvalidText { offset }
666 | EdifactError::UnexpectedEof { offset }
667 | EdifactError::UnexpectedDataToken { offset }
668 | EdifactError::SegmentTooLong { offset, .. } => {
669 issue = issue.with_span(Span::new(offset, offset));
670 }
671 _ => {}
672 }
673
674 if issue.suggestion.is_none() {
675 if let Some(hint) = default_hint {
676 issue = issue.with_suggestion(hint);
677 }
678 }
679
680 issue
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::model::Element;
687
688 fn demo_orders_profile_pack() -> ProfileRulePack {
689 ProfileRulePack::new("ORDERS-DEMO")
690 .for_message_type("ORDERS")
691 .with_rule_fn(|segments, issues| {
692 issues.extend((|| -> Option<ValidationIssue> {
693 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
694 let document_code = bgm.get_element(0)?.get_component(0)?;
695 (document_code == "220").then(|| {
696 ValidationIssue::new(
697 ValidationSeverity::Error,
698 "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
699 )
700 .with_rule_id("DEMO-P001")
701 .with_segment("BGM")
702 .with_element_index(0)
703 .with_suggestion("Use a different BGM document code in this demo pack")
704 })
705 })());
706 })
707 .with_rule_fn(|segments, issues| {
708 issues.extend((|| -> Option<ValidationIssue> {
709 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
710 let reference = bgm.get_element(1)?.get_component(0)?;
711 (reference == "PO123").then(|| {
712 ValidationIssue::new(
713 ValidationSeverity::Warning,
714 "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
715 )
716 .with_rule_id("DEMO-P002")
717 .with_segment("BGM")
718 .with_element_index(1)
719 .with_suggestion("Use a non-reserved reference in this demo pack")
720 })
721 })());
722 })
723 }
724
725 struct RejectBgm;
726
727 struct WarnBgm;
728
729 impl Validator for RejectBgm {
730 fn validate_batch(
731 &self,
732 segments: &[Segment<'_>],
733 report: &mut ValidationReport,
734 _context: &ValidationRuleContext<'_>,
735 ) {
736 validate_each(segments, report, |segment| {
737 if segment.tag == "BGM" {
738 return Err(EdifactError::InvalidSegmentForMessage {
739 tag: "BGM".to_owned(),
740 message_type: "TEST".to_owned(),
741 span: segment.tag_span,
742 });
743 }
744 Ok(())
745 });
746 }
747 }
748
749 impl Validator for WarnBgm {
750 fn validate_batch(
751 &self,
752 segments: &[Segment<'_>],
753 report: &mut ValidationReport,
754 _context: &ValidationRuleContext<'_>,
755 ) {
756 validate_each(segments, report, |segment| {
757 if segment.tag == "BGM" {
758 return Err(EdifactError::InvalidCodeValue {
759 tag: "BGM".to_owned(),
760 element_index: 0,
761 value: "XXX".to_owned(),
762 code_list: "1001".to_owned(),
763 span: segment.span,
764 suggestion: None,
765 });
766 }
767 Ok(())
768 });
769 }
770 }
771
772 fn test_segment(tag: &'static str) -> Segment<'static> {
773 Segment::new(tag, vec![Element::of(&["x"])])
774 }
775
776 #[test]
777 fn lenient_collects_issues() {
778 let segments = vec![test_segment("UNH"), test_segment("BGM")];
779 let mut report = ValidationReport::default();
780 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
781 assert!(report.has_errors());
782 assert_eq!(report.errors().len(), 1);
783 }
784
785 #[test]
786 fn strict_fails_on_errors() {
787 let segments = vec![test_segment("BGM")];
788 let mut report = ValidationReport::default();
789 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
790 assert!(report.has_errors());
791 assert_eq!(report.errors().len(), 1);
792 }
793
794 #[test]
795 fn context_builder_respects_layer_toggles() {
796 let segments = vec![test_segment("BGM")];
797 let ctx = ValidationContext::builder()
798 .structure(false)
799 .with_validator(ValidationLayer::Structure, RejectBgm)
800 .with_validator(ValidationLayer::CodeList, WarnBgm)
801 .build();
802
803 let report = ctx.validate(&segments);
804 assert!(!report.has_errors());
805 assert_eq!(report.warnings().len(), 1);
806 }
807
808 #[test]
809 fn context_strict_fails_when_structure_enabled() {
810 let segments = vec![test_segment("BGM")];
811 let ctx = ValidationContext::builder()
812 .with_message_type("ORDERS")
813 .with_validator(ValidationLayer::Structure, RejectBgm)
814 .build();
815
816 assert_eq!(ctx.message_type(), Some("ORDERS"));
817 let result = ctx.validate(&segments).result();
818 assert!(result.is_err());
819 assert!(result.unwrap_err().has_errors());
820 }
821
822 #[test]
823 fn report_error_applies_default_recovery_hint() {
824 let mut report = ValidationReport::default();
825 report_error(
826 &mut report,
827 EdifactError::InvalidReleaseSequence { offset: 9 },
828 );
829
830 let issue = report
831 .errors()
832 .first()
833 .expect("expected one issue in the report");
834 let hint = issue
835 .suggestion
836 .as_deref()
837 .expect("expected default hint to be set");
838 assert!(hint.contains("Release character"));
839 assert_eq!(issue.error_code(), Some("E019"));
840 }
841
842 #[test]
843 fn missing_required_component_maps_metadata_to_issue() {
844 let mut report = ValidationReport::default();
845 report_error(
846 &mut report,
847 EdifactError::MissingRequiredComponent {
848 tag: "BGM".to_owned(),
849 element_index: 2,
850 component_index: 1,
851 },
852 );
853
854 let issue = report.errors().first().expect("expected one issue");
855 assert_eq!(issue.error_code(), Some("E021"));
856 assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
857 assert_eq!(issue.element_index, Some(2));
858 assert_eq!(issue.component_index, Some(1));
859 }
860
861 #[test]
862 fn profile_pack_lenient_collects_profile_rule_issues() {
863 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
864 let segments = crate::from_bytes(input)
865 .collect::<Result<Vec<_>, _>>()
866 .expect("expected parse success");
867
868 let ctx = ValidationContext::builder()
869 .with_profile_pack(demo_orders_profile_pack())
870 .build();
871
872 let report = ctx.validate(&segments);
873 assert!(report.has_errors());
874 assert!(
875 report
876 .errors()
877 .iter()
878 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
879 );
880 assert!(
881 report
882 .warnings()
883 .iter()
884 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
885 );
886 }
887
888 #[test]
889 fn profile_pack_strict_fails_when_profile_errors_exist() {
890 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
891 let segments = crate::from_bytes(input)
892 .collect::<Result<Vec<_>, _>>()
893 .expect("expected parse success");
894
895 let ctx = ValidationContext::builder()
896 .with_profile_pack(demo_orders_profile_pack())
897 .build();
898 let result = ctx.validate(&segments).result();
899 assert!(result.is_err());
900 assert!(result.unwrap_err().has_errors());
901 }
902
903 fn two_dtm_errors_rule() -> ProfileRulePack {
907 ProfileRulePack::new("TEST-BAIL")
908 .with_rule_fn(|segments, issues| {
909 for seg in segments.iter().filter(|s| s.tag == "DTM") {
911 issues.push(
912 ValidationIssue::new(
913 ValidationSeverity::Error,
914 format!("DTM error at offset {}", seg.span.start),
915 )
916 .with_rule_id("BAIL-R1")
917 .with_segment("DTM"),
918 );
919 }
920 })
921 .with_rule_fn(|segments, issues| {
922 for seg in segments.iter().filter(|s| s.tag == "BGM") {
924 issues.push(
925 ValidationIssue::new(ValidationSeverity::Error, "BGM error")
926 .with_rule_id("BAIL-R2")
927 .with_segment(seg.tag()),
928 );
929 }
930 })
931 }
932
933 #[test]
934 fn bail_on_first_error_fires_at_rule_invocation_granularity() {
935 let input =
938 b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
939 let segments = crate::from_bytes(input)
940 .collect::<Result<Vec<_>, _>>()
941 .expect("parse failed");
942
943 let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
944 let ctx = ValidationContext::builder()
945 .with_profile_pack(pack_with_bail)
946 .build();
947 let report = ctx.validate(&segments);
948
949 assert_eq!(
952 report
953 .errors()
954 .iter()
955 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
956 .count(),
957 2,
958 "both DTM errors from Rule A should be present"
959 );
960 assert_eq!(
962 report
963 .errors()
964 .iter()
965 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
966 .count(),
967 0,
968 "Rule B should have been skipped by bail"
969 );
970 }
971
972 #[test]
973 fn bail_disabled_runs_all_rules() {
974 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
975 let segments = crate::from_bytes(input)
976 .collect::<Result<Vec<_>, _>>()
977 .expect("parse failed");
978
979 let pack_no_bail = two_dtm_errors_rule(); let ctx = ValidationContext::builder()
981 .with_profile_pack(pack_no_bail)
982 .build();
983 let report = ctx.validate(&segments);
984
985 assert_eq!(
987 report
988 .errors()
989 .iter()
990 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
991 .count(),
992 1
993 );
994 assert_eq!(
995 report
996 .errors()
997 .iter()
998 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
999 .count(),
1000 1
1001 );
1002 }
1003
1004 #[test]
1007 fn message_ref_is_visible_inside_rule_closure() {
1008 let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
1009 let segments = crate::from_bytes(input)
1010 .collect::<Result<Vec<_>, _>>()
1011 .expect("parse failed");
1012
1013 let pack =
1014 ProfileRulePack::new("MSG-REF-TEST").with_contextual_rule_fn(|_segs, ctx, issues| {
1015 if let Some(mref) = ctx.message_ref {
1016 issues.push(
1017 ValidationIssue::new(
1018 ValidationSeverity::Info,
1019 format!("validating message {mref}"),
1020 )
1021 .with_rule_id("CTX-REF"),
1022 );
1023 }
1024 });
1025
1026 let ctx = ValidationContext::builder()
1027 .with_profile_pack(pack)
1028 .with_message_ref("MSG001")
1029 .build();
1030
1031 let report = ctx.validate(&segments);
1032 let info = report
1033 .infos()
1034 .iter()
1035 .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
1036 .expect("expected info issue from CTX-REF rule");
1037 assert!(info.message.contains("MSG001"));
1038 assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
1040 }
1041}