Skip to main content

edifact_rs/validator/
mod.rs

1//! Validation pipeline for structural and semantic EDIFACT checks.
2//!
3//! The validation module is split into three sub-modules:
4//!
5//! - [`pack`] — [`ProfileRule`], [`ProfileRulePack`], and rule construction helpers.
6//! - [`context`] — [`ValidationContext`], [`ValidationContextBuilder`].
7//! - This module (`mod.rs`) — [`Validator`] trait, [`ValidationRuleContext`],
8//!   [`ValidationLayer`], [`EnvelopeValidator`], and helper functions.
9
10pub mod context;
11pub mod pack;
12
13pub use context::{ValidationContext, ValidationContextBuilder};
14pub use pack::{ProfileRule, ProfileRulePack};
15
16use crate::{EdifactError, Segment, ValidationIssue, ValidationReport, ValidationSeverity};
17use std::any::Any;
18
19/// Typed context injected into profile rule closures at validation time.
20///
21/// Rules access per-call metadata via [`ValidationRuleContext::metadata`] and
22/// the message reference (UNH element 0) via [`ValidationRuleContext::message_ref`].
23///
24/// # Example
25///
26/// ```rust,ignore
27/// let pack = ProfileRulePack::new("AHB-11001")
28///     .with_rule_fn(|segs, ctx, issues| {
29///         let Some(pruefid) = ctx.metadata::<Pruefid>() else { return };
30///         let msg_ref = ctx.message_ref.unwrap_or("<unknown>");
31///     });
32/// ```
33#[derive(Clone, Copy)]
34pub struct ValidationRuleContext<'a> {
35    pub(super) metadata: Option<&'a (dyn Any + Send + Sync)>,
36    /// Message reference (`UNH` element 0) for this validation call.
37    pub message_ref: Option<&'a str>,
38    /// EDIFACT message type extracted from `UNH` element 1 component 0.
39    ///
40    /// Pre-extracted once by [`ValidationContext`] before dispatching to validators,
41    /// so each [`ProfileRulePack`] can skip its own `UNH` scan.
42    /// `None` when the message type could not be determined (no `UNH`, or when calling
43    /// [`ProfileRulePack::validate_batch`] directly without a `ValidationContext`).
44    pub message_type: Option<&'a str>,
45}
46
47impl<'a> ValidationRuleContext<'a> {
48    /// Construct a context with no metadata and no message reference.
49    pub fn empty() -> Self {
50        Self {
51            metadata: None,
52            message_ref: None,
53            message_type: None,
54        }
55    }
56
57    /// Construct a context holding a typed metadata reference.
58    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    /// Attach a message reference to this context (builder-style).
67    pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
68        self.message_ref = Some(msg_ref);
69        self
70    }
71
72    /// Attach a pre-extracted message type to this context (builder-style).
73    pub fn with_message_type(mut self, message_type: &'a str) -> Self {
74        self.message_type = Some(message_type);
75        self
76    }
77
78    /// Downcast the metadata to `T`.  Returns `None` if no metadata was
79    /// injected or if the concrete type does not match `T`.
80    pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
81        self.metadata?.downcast_ref::<T>()
82    }
83
84    /// Return `true` if metadata was provided.
85    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/// Validation layers used by [`ValidationContext`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum ValidationLayer {
104    /// Interchange / message envelope checks (`UNB`/`UNH`/`UNT`/`UNZ` counts).
105    Envelope,
106    /// Directory structure checks (segment presence/order/arity).
107    Structure,
108    /// Directory code-list checks.
109    CodeList,
110    /// Downstream profile-pack checks.
111    Profile,
112}
113
114/// Pluggable validator for parsed EDIFACT segments.
115///
116/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
117/// entire segment sequence and appends issues to a [`ValidationReport`].
118pub trait Validator: Send + Sync {
119    /// Validate a full segment set and append issues to `report`.
120    fn validate_batch(
121        &self,
122        segments: &[Segment<'_>],
123        report: &mut ValidationReport,
124        context: &ValidationRuleContext<'_>,
125    );
126
127    /// Validate a segment-group tree and append issues to `report`.
128    ///
129    /// Called by [`ValidationContext::validate_lenient_grouped`] in addition to
130    /// [`validate_batch`](Validator::validate_batch).  Validators that only perform
131    /// flat segment checks (e.g. [`EnvelopeValidator`]) can leave this as the
132    /// default no-op; only validators with group-scoped rules (typically
133    /// [`ProfileRulePack`] with at least one group rule) need to override it.
134    ///
135    /// The default implementation is a no-op so that adding this method to the
136    /// trait is not a breaking change for external `Validator` implementors.
137    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    /// Returns `true` if this validator has any group-scoped rules.
147    ///
148    /// Used by [`crate::ValidationContext`] to short-circuit the
149    /// group-tree walk when no validator in the context has group rules,
150    /// avoiding the cost of allocating a borrowed slice for nothing.
151    ///
152    /// The default implementation returns `false`.
153    fn has_group_rules(&self) -> bool {
154        false
155    }
156
157    /// Configure message-type metadata for validators that support explicit scoping.
158    fn set_message_type(&mut self, _message_type: Option<&str>) {}
159
160    /// Create a `Box<dyn Validator>` clone of this validator for context forking.
161    ///
162    /// Return `Some(boxed_clone)` for validators that support cheap forking
163    /// (e.g. those backed by `Arc` data, like [`ProfileRulePack`]).
164    ///
165    /// Return `None` for validators that cannot be forked (e.g. stateful validators
166    /// without `Clone`).  Returning `None` causes the validator to be silently
167    /// **excluded** from forked contexts — forking is used by
168    /// [`crate::ValidationContext::validate_lenient_grouped`] to validate
169    /// each group in isolation, so omitting a non-forkable validator from the
170    /// forked context is safer than panicking.
171    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
172        None
173    }
174}
175
176/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
177/// and converts any `Err` into report entries.
178pub 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
189/// Convert a low-level validation error to a user-facing issue and append it.
190///
191/// # Severity mapping
192///
193/// The mapping from `EdifactError` variant to `ValidationSeverity` is:
194///
195/// | Variant | Severity |
196/// |---|---|
197/// | `InvalidCodeValue` | `Warning` |
198/// | `QualifierMismatch` | `Warning` |
199/// | *(everything else)* | `Error` |
200///
201/// Rationale: code-list and qualifier mismatches indicate a value that *could*
202/// be intentional (non-standard extension codes are common in practice).  All
203/// structural violations — missing segments, wrong element counts, parse errors
204/// — are hard errors.
205pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
206    let issue = issue_from_error(err);
207    match issue.severity {
208        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
209        ValidationSeverity::Warning => report.add_warning(issue),
210        ValidationSeverity::Info => report.add_info(issue),
211    }
212}
213
214// ── EnvelopeValidator ─────────────────────────────────────────────────────────
215
216/// Built-in validator for EDIFACT interchange envelope structure.
217///
218/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
219/// segment counts.  Registered by
220/// [`ValidationContextBuilder::with_envelope_validation`].
221pub struct EnvelopeValidator;
222
223impl Validator for EnvelopeValidator {
224    fn validate_batch(
225        &self,
226        segments: &[Segment<'_>],
227        report: &mut ValidationReport,
228        _ctx: &ValidationRuleContext<'_>,
229    ) {
230        if let Err(e) = crate::envelope::validate_envelope(segments) {
231            report_error(report, e);
232        }
233    }
234
235    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
236        Some(Box::new(EnvelopeValidator))
237    }
238}
239
240fn issue_from_error(err: EdifactError) -> ValidationIssue {
241    let code = err.stable_code();
242    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
243    let default_hint = err.recovery_hint();
244
245    match err {
246        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
247            issue = issue.with_segment(tag).with_offset(offset);
248        }
249        EdifactError::InvalidElementCount { tag, offset, .. } => {
250            issue = issue.with_segment(tag).with_offset(offset);
251        }
252        EdifactError::InvalidComponentCount {
253            tag,
254            element_index,
255            offset,
256            ..
257        } => {
258            issue = issue
259                .with_segment(tag)
260                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
261                .with_offset(offset);
262        }
263        EdifactError::InvalidCodeValue {
264            tag,
265            element_index,
266            offset,
267            suggestion,
268            ..
269        } => {
270            issue = issue
271                .with_segment(tag)
272                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
273                .with_offset(offset);
274            if let Some(s) = suggestion {
275                issue = issue.with_suggestion(s);
276            }
277        }
278        EdifactError::MissingSegment { tag, .. } => {
279            issue = issue.with_segment(tag);
280        }
281        EdifactError::QualifierMismatch { tag, offset, .. } => {
282            issue = issue
283                .with_segment(tag)
284                .with_element_index(0)
285                .with_offset(offset);
286        }
287        EdifactError::ConditionalRequirementNotMet {
288            tag,
289            element_index,
290            offset,
291            ..
292        } => {
293            issue = issue
294                .with_segment(tag)
295                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
296                .with_offset(offset);
297        }
298        EdifactError::MissingRequiredElement { tag, element_index } => {
299            issue = issue.with_segment(tag);
300            if let Ok(idx) = u8::try_from(element_index) {
301                issue = issue.with_element_index(idx);
302            }
303        }
304        EdifactError::MissingRequiredComponent {
305            tag,
306            element_index,
307            component_index,
308        } => {
309            issue = issue.with_segment(tag);
310            if let Ok(ei) = u8::try_from(element_index) {
311                issue = issue.with_element_index(ei);
312            }
313            if let Ok(ci) = u8::try_from(component_index) {
314                issue = issue.with_component_index(ci);
315            }
316        }
317        EdifactError::InvalidReleaseSequence { offset }
318        | EdifactError::InvalidDelimiter { offset, .. }
319        | EdifactError::InvalidText { offset }
320        | EdifactError::UnexpectedEof { offset }
321        | EdifactError::UnexpectedDataToken { offset }
322        | EdifactError::FunctionalGroupNotSupported { offset } => {
323            issue = issue.with_offset(offset);
324        }
325        _ => {}
326    }
327
328    if issue.suggestion.is_none() {
329        if let Some(hint) = default_hint {
330            issue = issue.with_suggestion(hint);
331        }
332    }
333
334    issue
335}
336
337fn severity_for(err: &EdifactError) -> ValidationSeverity {
338    match err {
339        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
340            ValidationSeverity::Warning
341        }
342        _ => ValidationSeverity::Error,
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::model::Element;
350
351    fn demo_orders_profile_pack() -> ProfileRulePack {
352        ProfileRulePack::new("ORDERS-DEMO")
353            .for_message_type("ORDERS")
354            .with_stateless_rule_fn(|segments, issues| {
355                issues.extend((|| -> Option<ValidationIssue> {
356                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
357                    let document_code = bgm.get_element(0)?.get_component(0)?;
358                    (document_code == "220").then(|| {
359                        ValidationIssue::new(
360                            ValidationSeverity::Error,
361                            "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
362                        )
363                        .with_rule_id("DEMO-P001")
364                        .with_segment("BGM")
365                        .with_element_index(0)
366                        .with_suggestion("Use a different BGM document code in this demo pack")
367                    })
368                })());
369            })
370            .with_stateless_rule_fn(|segments, issues| {
371                issues.extend((|| -> Option<ValidationIssue> {
372                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
373                    let reference = bgm.get_element(1)?.get_component(0)?;
374                    (reference == "PO123").then(|| {
375                        ValidationIssue::new(
376                            ValidationSeverity::Warning,
377                            "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
378                        )
379                        .with_rule_id("DEMO-P002")
380                        .with_segment("BGM")
381                        .with_element_index(1)
382                        .with_suggestion("Use a non-reserved reference in this demo pack")
383                    })
384                })());
385            })
386    }
387
388    struct RejectBgm;
389
390    struct WarnBgm;
391
392    impl Validator for RejectBgm {
393        fn validate_batch(
394            &self,
395            segments: &[Segment<'_>],
396            report: &mut ValidationReport,
397            _context: &ValidationRuleContext<'_>,
398        ) {
399            validate_each(segments, report, |segment| {
400                if segment.tag == "BGM" {
401                    return Err(EdifactError::InvalidSegmentForMessage {
402                        tag: "BGM".to_owned(),
403                        message_type: "TEST".to_owned(),
404                        offset: segment.tag_span.start,
405                    });
406                }
407                Ok(())
408            });
409        }
410    }
411
412    impl Validator for WarnBgm {
413        fn validate_batch(
414            &self,
415            segments: &[Segment<'_>],
416            report: &mut ValidationReport,
417            _context: &ValidationRuleContext<'_>,
418        ) {
419            validate_each(segments, report, |segment| {
420                if segment.tag == "BGM" {
421                    return Err(EdifactError::InvalidCodeValue {
422                        tag: "BGM".to_owned(),
423                        element_index: 0,
424                        value: "XXX".to_owned(),
425                        code_list: "1001".to_owned(),
426                        offset: segment.span.start,
427                        suggestion: None,
428                    });
429                }
430                Ok(())
431            });
432        }
433    }
434
435    fn test_segment(tag: &'static str) -> Segment<'static> {
436        Segment {
437            tag,
438            span: crate::Span::new(0, 0),
439            tag_span: crate::Span::new(0, 0),
440            elements: vec![Element::of(&["x"])],
441        }
442    }
443
444    #[test]
445    fn lenient_collects_issues() {
446        let segments = vec![test_segment("UNH"), test_segment("BGM")];
447        let mut report = ValidationReport::default();
448        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
449        assert!(report.has_errors());
450        assert_eq!(report.errors().len(), 1);
451    }
452
453    #[test]
454    fn strict_fails_on_errors() {
455        let segments = vec![test_segment("BGM")];
456        let mut report = ValidationReport::default();
457        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
458        assert!(report.has_errors());
459        assert_eq!(report.errors().len(), 1);
460    }
461
462    #[test]
463    fn context_builder_respects_layer_toggles() {
464        let segments = vec![test_segment("BGM")];
465        let ctx = ValidationContext::builder()
466            .structure(false)
467            .with_validator(ValidationLayer::Structure, RejectBgm)
468            .with_validator(ValidationLayer::CodeList, WarnBgm)
469            .build();
470
471        let report = ctx.validate_lenient(&segments);
472        assert!(!report.has_errors());
473        assert_eq!(report.warnings().len(), 1);
474    }
475
476    #[test]
477    fn context_strict_fails_when_structure_enabled() {
478        let segments = vec![test_segment("BGM")];
479        let ctx = ValidationContext::builder()
480            .with_message_type("ORDERS")
481            .with_validator(ValidationLayer::Structure, RejectBgm)
482            .build();
483
484        assert_eq!(ctx.message_type(), Some("ORDERS"));
485        let result = ctx.validate_strict(&segments);
486        assert!(result.is_err());
487        assert!(result.unwrap_err().has_errors());
488    }
489
490    #[test]
491    fn report_error_applies_default_recovery_hint() {
492        let mut report = ValidationReport::default();
493        report_error(
494            &mut report,
495            EdifactError::InvalidReleaseSequence { offset: 9 },
496        );
497
498        let issue = report
499            .errors()
500            .first()
501            .expect("expected one issue in the report");
502        let hint = issue
503            .suggestion
504            .as_deref()
505            .expect("expected default hint to be set");
506        assert!(hint.contains("Release character"));
507        assert_eq!(issue.error_code, Some("E019"));
508    }
509
510    #[test]
511    fn missing_required_component_maps_metadata_to_issue() {
512        let mut report = ValidationReport::default();
513        report_error(
514            &mut report,
515            EdifactError::MissingRequiredComponent {
516                tag: "BGM".to_owned(),
517                element_index: 2,
518                component_index: 1,
519            },
520        );
521
522        let issue = report.errors().first().expect("expected one issue");
523        assert_eq!(issue.error_code, Some("E021"));
524        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
525        assert_eq!(issue.element_index, Some(2));
526        assert_eq!(issue.component_index, Some(1));
527    }
528
529    #[test]
530    fn profile_pack_lenient_collects_profile_rule_issues() {
531        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
532        let segments = crate::from_bytes(input)
533            .collect::<Result<Vec<_>, _>>()
534            .expect("expected parse success");
535
536        let ctx = ValidationContext::builder()
537            .with_profile_pack(demo_orders_profile_pack())
538            .build();
539
540        let report = ctx.validate_lenient(&segments);
541        assert!(report.has_errors());
542        assert!(
543            report
544                .errors()
545                .iter()
546                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
547        );
548        assert!(
549            report
550                .warnings()
551                .iter()
552                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
553        );
554    }
555
556    #[test]
557    fn profile_pack_strict_fails_when_profile_errors_exist() {
558        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
559        let segments = crate::from_bytes(input)
560            .collect::<Result<Vec<_>, _>>()
561            .expect("expected parse success");
562
563        let ctx = ValidationContext::builder()
564            .with_profile_pack(demo_orders_profile_pack())
565            .build();
566        let result = ctx.validate_strict(&segments);
567        assert!(result.is_err());
568        assert!(result.unwrap_err().has_errors());
569    }
570
571    // ── bail_on_first_error ──────────────────────────────────────────────────
572
573    /// A rule that emits two error-severity issues (one per DTM segment).
574    fn two_dtm_errors_rule() -> ProfileRulePack {
575        ProfileRulePack::new("TEST-BAIL")
576            .with_stateless_rule_fn(|segments, issues| {
577                // Rule A: emits one error per DTM segment.
578                for seg in segments.iter().filter(|s| s.tag == "DTM") {
579                    issues.push(
580                        ValidationIssue::new(
581                            ValidationSeverity::Error,
582                            format!("DTM error at offset {}", seg.span.start),
583                        )
584                        .with_rule_id("BAIL-R1")
585                        .with_segment("DTM"),
586                    );
587                }
588            })
589            .with_stateless_rule_fn(|segments, issues| {
590                // Rule B: never fires; used to verify bail skips this rule.
591                for seg in segments.iter().filter(|s| s.tag == "BGM") {
592                    issues.push(
593                        ValidationIssue::new(ValidationSeverity::Error, "BGM error")
594                            .with_rule_id("BAIL-R2")
595                            .with_segment(seg.tag),
596                    );
597                }
598            })
599    }
600
601    #[test]
602    fn bail_on_first_error_fires_at_rule_invocation_granularity() {
603        // Two DTM segments → Rule A emits 2 errors for them.
604        // With bail, Rule B (BGM check) must NOT run.
605        let input =
606            b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
607        let segments = crate::from_bytes(input)
608            .collect::<Result<Vec<_>, _>>()
609            .expect("parse failed");
610
611        let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
612        let ctx = ValidationContext::builder()
613            .with_profile_pack(pack_with_bail)
614            .build();
615        let report = ctx.validate_lenient(&segments);
616
617        // Rule A fires: both DTM errors are in the report (the whole rule invocation
618        // runs to completion before bail is checked).
619        assert_eq!(
620            report
621                .errors()
622                .iter()
623                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
624                .count(),
625            2,
626            "both DTM errors from Rule A should be present"
627        );
628        // Bail fired after Rule A: Rule B (BGM) must be skipped.
629        assert_eq!(
630            report
631                .errors()
632                .iter()
633                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
634                .count(),
635            0,
636            "Rule B should have been skipped by bail"
637        );
638    }
639
640    #[test]
641    fn bail_disabled_runs_all_rules() {
642        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
643        let segments = crate::from_bytes(input)
644            .collect::<Result<Vec<_>, _>>()
645            .expect("parse failed");
646
647        let pack_no_bail = two_dtm_errors_rule(); // bail_on_first_error defaults to false
648        let ctx = ValidationContext::builder()
649            .with_profile_pack(pack_no_bail)
650            .build();
651        let report = ctx.validate_lenient(&segments);
652
653        // Both rules run: one DTM error from Rule A, one BGM error from Rule B.
654        assert_eq!(
655            report
656                .errors()
657                .iter()
658                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
659                .count(),
660            1
661        );
662        assert_eq!(
663            report
664                .errors()
665                .iter()
666                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
667                .count(),
668            1
669        );
670    }
671
672    // ── message_ref in ValidationRuleContext ─────────────────────────────────
673
674    #[test]
675    fn message_ref_is_visible_inside_rule_closure() {
676        let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
677        let segments = crate::from_bytes(input)
678            .collect::<Result<Vec<_>, _>>()
679            .expect("parse failed");
680
681        let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
682            if let Some(mref) = ctx.message_ref {
683                issues.push(
684                    ValidationIssue::new(
685                        ValidationSeverity::Info,
686                        format!("validating message {mref}"),
687                    )
688                    .with_rule_id("CTX-REF"),
689                );
690            }
691        });
692
693        let ctx = ValidationContext::builder()
694            .with_profile_pack(pack)
695            .with_message_ref("MSG001")
696            .build();
697
698        let report = ctx.validate_lenient(&segments);
699        let info = report
700            .infos()
701            .iter()
702            .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
703            .expect("expected info issue from CTX-REF rule");
704        assert!(info.message.contains("MSG001"));
705        // The message_ref is also stamped onto the issue itself.
706        assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
707    }
708}