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_stateless_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_stateless_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 {
774 tag,
775 span: crate::Span::new(0, 0),
776 tag_span: crate::Span::new(0, 0),
777 elements: vec![Element::of(&["x"])],
778 }
779 }
780
781 #[test]
782 fn lenient_collects_issues() {
783 let segments = vec![test_segment("UNH"), test_segment("BGM")];
784 let mut report = ValidationReport::default();
785 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
786 assert!(report.has_errors());
787 assert_eq!(report.errors().len(), 1);
788 }
789
790 #[test]
791 fn strict_fails_on_errors() {
792 let segments = vec![test_segment("BGM")];
793 let mut report = ValidationReport::default();
794 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
795 assert!(report.has_errors());
796 assert_eq!(report.errors().len(), 1);
797 }
798
799 #[test]
800 fn context_builder_respects_layer_toggles() {
801 let segments = vec![test_segment("BGM")];
802 let ctx = ValidationContext::builder()
803 .structure(false)
804 .with_validator(ValidationLayer::Structure, RejectBgm)
805 .with_validator(ValidationLayer::CodeList, WarnBgm)
806 .build();
807
808 let report = ctx.validate_lenient(&segments);
809 assert!(!report.has_errors());
810 assert_eq!(report.warnings().len(), 1);
811 }
812
813 #[test]
814 fn context_strict_fails_when_structure_enabled() {
815 let segments = vec![test_segment("BGM")];
816 let ctx = ValidationContext::builder()
817 .with_message_type("ORDERS")
818 .with_validator(ValidationLayer::Structure, RejectBgm)
819 .build();
820
821 assert_eq!(ctx.message_type(), Some("ORDERS"));
822 let result = ctx.validate_strict(&segments);
823 assert!(result.is_err());
824 assert!(result.unwrap_err().has_errors());
825 }
826
827 #[test]
828 fn report_error_applies_default_recovery_hint() {
829 let mut report = ValidationReport::default();
830 report_error(
831 &mut report,
832 EdifactError::InvalidReleaseSequence { offset: 9 },
833 );
834
835 let issue = report
836 .errors()
837 .first()
838 .expect("expected one issue in the report");
839 let hint = issue
840 .suggestion
841 .as_deref()
842 .expect("expected default hint to be set");
843 assert!(hint.contains("Release character"));
844 assert_eq!(issue.error_code(), Some("E019"));
845 }
846
847 #[test]
848 fn missing_required_component_maps_metadata_to_issue() {
849 let mut report = ValidationReport::default();
850 report_error(
851 &mut report,
852 EdifactError::MissingRequiredComponent {
853 tag: "BGM".to_owned(),
854 element_index: 2,
855 component_index: 1,
856 },
857 );
858
859 let issue = report.errors().first().expect("expected one issue");
860 assert_eq!(issue.error_code(), Some("E021"));
861 assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
862 assert_eq!(issue.element_index, Some(2));
863 assert_eq!(issue.component_index, Some(1));
864 }
865
866 #[test]
867 fn profile_pack_lenient_collects_profile_rule_issues() {
868 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
869 let segments = crate::from_bytes(input)
870 .collect::<Result<Vec<_>, _>>()
871 .expect("expected parse success");
872
873 let ctx = ValidationContext::builder()
874 .with_profile_pack(demo_orders_profile_pack())
875 .build();
876
877 let report = ctx.validate_lenient(&segments);
878 assert!(report.has_errors());
879 assert!(
880 report
881 .errors()
882 .iter()
883 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
884 );
885 assert!(
886 report
887 .warnings()
888 .iter()
889 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
890 );
891 }
892
893 #[test]
894 fn profile_pack_strict_fails_when_profile_errors_exist() {
895 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
896 let segments = crate::from_bytes(input)
897 .collect::<Result<Vec<_>, _>>()
898 .expect("expected parse success");
899
900 let ctx = ValidationContext::builder()
901 .with_profile_pack(demo_orders_profile_pack())
902 .build();
903 let result = ctx.validate_strict(&segments);
904 assert!(result.is_err());
905 assert!(result.unwrap_err().has_errors());
906 }
907
908 fn two_dtm_errors_rule() -> ProfileRulePack {
912 ProfileRulePack::new("TEST-BAIL")
913 .with_stateless_rule_fn(|segments, issues| {
914 for seg in segments.iter().filter(|s| s.tag == "DTM") {
916 issues.push(
917 ValidationIssue::new(
918 ValidationSeverity::Error,
919 format!("DTM error at offset {}", seg.span.start),
920 )
921 .with_rule_id("BAIL-R1")
922 .with_segment("DTM"),
923 );
924 }
925 })
926 .with_stateless_rule_fn(|segments, issues| {
927 for seg in segments.iter().filter(|s| s.tag == "BGM") {
929 issues.push(
930 ValidationIssue::new(ValidationSeverity::Error, "BGM error")
931 .with_rule_id("BAIL-R2")
932 .with_segment(seg.tag),
933 );
934 }
935 })
936 }
937
938 #[test]
939 fn bail_on_first_error_fires_at_rule_invocation_granularity() {
940 let input =
943 b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
944 let segments = crate::from_bytes(input)
945 .collect::<Result<Vec<_>, _>>()
946 .expect("parse failed");
947
948 let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
949 let ctx = ValidationContext::builder()
950 .with_profile_pack(pack_with_bail)
951 .build();
952 let report = ctx.validate_lenient(&segments);
953
954 assert_eq!(
957 report
958 .errors()
959 .iter()
960 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
961 .count(),
962 2,
963 "both DTM errors from Rule A should be present"
964 );
965 assert_eq!(
967 report
968 .errors()
969 .iter()
970 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
971 .count(),
972 0,
973 "Rule B should have been skipped by bail"
974 );
975 }
976
977 #[test]
978 fn bail_disabled_runs_all_rules() {
979 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
980 let segments = crate::from_bytes(input)
981 .collect::<Result<Vec<_>, _>>()
982 .expect("parse failed");
983
984 let pack_no_bail = two_dtm_errors_rule(); let ctx = ValidationContext::builder()
986 .with_profile_pack(pack_no_bail)
987 .build();
988 let report = ctx.validate_lenient(&segments);
989
990 assert_eq!(
992 report
993 .errors()
994 .iter()
995 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
996 .count(),
997 1
998 );
999 assert_eq!(
1000 report
1001 .errors()
1002 .iter()
1003 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
1004 .count(),
1005 1
1006 );
1007 }
1008
1009 #[test]
1012 fn message_ref_is_visible_inside_rule_closure() {
1013 let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
1014 let segments = crate::from_bytes(input)
1015 .collect::<Result<Vec<_>, _>>()
1016 .expect("parse failed");
1017
1018 let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
1019 if let Some(mref) = ctx.message_ref {
1020 issues.push(
1021 ValidationIssue::new(
1022 ValidationSeverity::Info,
1023 format!("validating message {mref}"),
1024 )
1025 .with_rule_id("CTX-REF"),
1026 );
1027 }
1028 });
1029
1030 let ctx = ValidationContext::builder()
1031 .with_profile_pack(pack)
1032 .with_message_ref("MSG001")
1033 .build();
1034
1035 let report = ctx.validate_lenient(&segments);
1036 let info = report
1037 .infos()
1038 .iter()
1039 .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
1040 .expect("expected info issue from CTX-REF rule");
1041 assert!(info.message.contains("MSG001"));
1042 assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
1044 }
1045}