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, Span, 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("PROFILE-4711")
28///     .with_rule_fn(|segs, ctx, issues| {
29///         let Some(process_id) = ctx.metadata::<ProcessId>() 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/// | *(everything else)* | `Error` |
199///
200/// Rationale: a code-list mismatch indicates a value that *could* be intentional,
201/// since non-standard extension codes are common in practice.  Everything else —
202/// structural violations, control-reference mismatches, parse errors — is a hard
203/// error.
204pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
205    let issue = issue_from_error(err);
206    match issue.severity {
207        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
208        ValidationSeverity::Warning => report.add_warning(issue),
209        ValidationSeverity::Info => report.add_info(issue),
210    }
211}
212
213// ── EnvelopeValidator ─────────────────────────────────────────────────────────
214
215/// Built-in validator for EDIFACT interchange envelope structure.
216///
217/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
218/// segment counts.  Registered by
219/// [`ValidationContextBuilder::with_envelope_validation`].
220pub struct EnvelopeValidator;
221
222impl Validator for EnvelopeValidator {
223    fn validate_batch(
224        &self,
225        segments: &[Segment<'_>],
226        report: &mut ValidationReport,
227        _ctx: &ValidationRuleContext<'_>,
228    ) {
229        // Use the lenient path so a single report surfaces every envelope
230        // violation.  The strict path stops at the first, which made
231        // `ValidationContext::validate_lenient` — whose whole purpose is
232        // exhaustive reporting — yield at most one envelope issue per batch.
233        for e in crate::envelope::validate_envelope_lenient(segments).errors {
234            report_error(report, e);
235        }
236    }
237
238    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
239        Some(Box::new(EnvelopeValidator))
240    }
241}
242
243// ── CharsetValidator ──────────────────────────────────────────────────────────
244
245/// Checks that every value in the message is expressible in the interchange's
246/// declared character repertoire (`UNB` S001 DE 0001).
247///
248/// This is the check partners actually enforce and that costs real money when it
249/// is missed: a `UNOA` interchange carrying a lower-case letter, or a `UNOC` one
250/// carrying `€`, is rejected at the far end — but only after it has been sent.
251///
252/// Values are checked as **decoded text**, so run this after
253/// [`decode_interchange`][crate::decode_interchange] (or on natively-ASCII
254/// input). It answers "can this text be written back out in the repertoire the
255/// header promises?", which is the question that matters.
256///
257/// Registered by
258/// [`ValidationContextBuilder::with_charset_validation`][crate::ValidationContextBuilder::with_charset_validation].
259///
260/// # Example
261///
262/// ```
263/// use edifact_rs::{from_bytes, ValidationContext};
264///
265/// // UNOA is upper-case only, but the party name is mixed case.
266/// let segments: Vec<_> = from_bytes(
267///     b"UNB+UNOA:3+S+R+200101:0900+1'NAD+BY+Acme Ltd'UNZ+0+1'",
268/// )
269/// .collect::<Result<_, _>>()?;
270///
271/// let report = ValidationContext::builder()
272///     .with_charset_validation()
273///     .build()
274///     .validate_lenient(&segments);
275///
276/// let issue = report.errors().iter().find(|i| i.error_code() == Some("E038")).unwrap();
277/// assert_eq!(issue.segment_tag.as_deref(), Some("NAD"));
278/// # Ok::<(), edifact_rs::EdifactError>(())
279/// ```
280pub struct CharsetValidator {
281    /// Repertoire to check against; `None` reads it from the interchange's `UNB`.
282    charset: Option<crate::Charset>,
283}
284
285impl CharsetValidator {
286    /// Read the repertoire from the interchange's own `UNB` S001 DE 0001.
287    ///
288    /// A slice with no `UNB` — a single message window, say — is not checked,
289    /// because nothing declares what it should be checked against.
290    #[must_use]
291    pub fn from_envelope() -> Self {
292        Self { charset: None }
293    }
294
295    /// Check against a fixed repertoire, whatever the `UNB` says.
296    ///
297    /// Use this for message-level slices that carry no `UNB`, or to hold a
298    /// partner to a stricter repertoire than the one they declare.
299    #[must_use]
300    pub fn with_charset(charset: crate::Charset) -> Self {
301        Self {
302            charset: Some(charset),
303        }
304    }
305
306    /// The repertoire in force for `segments`.
307    fn resolve(&self, segments: &[Segment<'_>]) -> Option<crate::Charset> {
308        if self.charset.is_some() {
309            return self.charset;
310        }
311        let identifier = segments
312            .iter()
313            .find(|s| s.tag == "UNB")
314            .and_then(|unb| unb.component_str(0, 0))?;
315        // An unrecognised or unsupported identifier is the envelope validator's
316        // finding to report, not this one's — silently declining to check is the
317        // right move rather than raising a second, confusing error for it.
318        crate::Charset::from_syntax_identifier(identifier).ok()
319    }
320}
321
322impl Validator for CharsetValidator {
323    fn validate_batch(
324        &self,
325        segments: &[Segment<'_>],
326        report: &mut ValidationReport,
327        _context: &ValidationRuleContext<'_>,
328    ) {
329        let Some(charset) = self.resolve(segments) else {
330            return;
331        };
332        if charset == crate::Charset::UnoY {
333            return; // Everything is permitted; nothing to check.
334        }
335        for segment in segments {
336            for (element_index, element) in segment.elements.iter().enumerate() {
337                for components in element.repetitions() {
338                    for (component_index, (value, span)) in components.iter().enumerate() {
339                        let Some((offset, character)) = charset.first_violation(value) else {
340                            continue;
341                        };
342                        let mut issue = ValidationIssue::new(
343                            ValidationSeverity::Error,
344                            format!(
345                                "character {character:?} is not in the {charset} character \
346                                 repertoire declared by UNB S001",
347                            ),
348                        )
349                        .with_error_code(
350                            EdifactError::CharacterNotInRepertoire {
351                                charset: charset.syntax_identifier(),
352                                character,
353                                offset,
354                            }
355                            .stable_code(),
356                        )
357                        .with_segment(segment.tag)
358                        .with_span(*span)
359                        .with_suggestion(
360                            "Transliterate the value, or declare a wider repertoire in UNB S001 \
361                             (UNOC for Latin-1, UNOY for UTF-8)",
362                        );
363                        if let Ok(index) = u8::try_from(element_index) {
364                            issue = issue.with_element_index(index);
365                        }
366                        if let Ok(index) = u8::try_from(component_index) {
367                            issue = issue.with_component_index(index);
368                        }
369                        report.add_error(issue);
370                    }
371                }
372            }
373        }
374    }
375
376    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
377        Some(Box::new(Self {
378            charset: self.charset,
379        }))
380    }
381}
382
383fn issue_from_error(err: EdifactError) -> ValidationIssue {
384    let code = err.stable_code();
385    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
386    let default_hint = err.recovery_hint();
387
388    match err {
389        EdifactError::InvalidSegmentForMessage { tag, span, .. } => {
390            issue = issue.with_segment(tag).with_span(span);
391        }
392        EdifactError::InvalidElementCount { tag, span, .. } => {
393            issue = issue.with_segment(tag).with_span(span);
394        }
395        EdifactError::InvalidComponentCount {
396            tag,
397            element_index,
398            span,
399            ..
400        } => {
401            issue = issue
402                .with_segment(tag)
403                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
404                .with_span(span);
405        }
406        EdifactError::InvalidCodeValue {
407            tag,
408            element_index,
409            span,
410            suggestion,
411            ..
412        } => {
413            issue = issue
414                .with_segment(tag)
415                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
416                .with_span(span);
417            if let Some(s) = suggestion {
418                issue = issue.with_suggestion(s);
419            }
420        }
421        EdifactError::MissingSegment { tag, .. } => {
422            issue = issue.with_segment(tag);
423        }
424        EdifactError::QualifierMismatch { tag, span, .. } => {
425            issue = issue
426                .with_segment(tag)
427                .with_element_index(0)
428                .with_span(span);
429        }
430        EdifactError::ConditionalRequirementNotMet {
431            tag,
432            element_index,
433            span,
434            ..
435        } => {
436            issue = issue
437                .with_segment(tag)
438                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
439                .with_span(span);
440        }
441        EdifactError::DuplicateReference { tag, span, .. } => {
442            issue = issue.with_segment(tag).with_span(span);
443        }
444        EdifactError::MissingRequiredElement { tag, element_index } => {
445            issue = issue.with_segment(tag);
446            if let Ok(idx) = u8::try_from(element_index) {
447                issue = issue.with_element_index(idx);
448            }
449        }
450        EdifactError::MissingRequiredComponent {
451            tag,
452            element_index,
453            component_index,
454        } => {
455            issue = issue.with_segment(tag);
456            if let Ok(ei) = u8::try_from(element_index) {
457                issue = issue.with_element_index(ei);
458            }
459            if let Ok(ci) = u8::try_from(component_index) {
460                issue = issue.with_component_index(ci);
461            }
462        }
463        // Lexical faults are a point in the byte stream, not a range: record a
464        // zero-width span so `span` stays the single positional field.
465        EdifactError::InvalidReleaseSequence { offset }
466        | EdifactError::InvalidDelimiter { offset, .. }
467        | EdifactError::InvalidText { offset }
468        | EdifactError::UnexpectedEof { offset }
469        | EdifactError::UnexpectedDataToken { offset }
470        | EdifactError::SegmentTooLong { offset, .. } => {
471            issue = issue.with_span(Span::new(offset, offset));
472        }
473        _ => {}
474    }
475
476    if issue.suggestion.is_none() {
477        if let Some(hint) = default_hint {
478            issue = issue.with_suggestion(hint);
479        }
480    }
481
482    issue
483}
484
485fn severity_for(err: &EdifactError) -> ValidationSeverity {
486    match err {
487        // A value outside a known code list may be an intentional non-standard
488        // extension, which is common in practice — so this is advisory.
489        //
490        // `QualifierMismatch` is deliberately *not* grouped here: it is only ever
491        // produced for UNZ/UNE/UNT control-reference mismatches, which are hard
492        // ISO 9735-1 structural violations.  Downgrading it let a spliced or
493        // truncated interchange pass `validate_strict`.
494        EdifactError::InvalidCodeValue { .. } => ValidationSeverity::Warning,
495        _ => ValidationSeverity::Error,
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::model::Element;
503
504    fn demo_orders_profile_pack() -> ProfileRulePack {
505        ProfileRulePack::new("ORDERS-DEMO")
506            .for_message_type("ORDERS")
507            .with_stateless_rule_fn(|segments, issues| {
508                issues.extend((|| -> Option<ValidationIssue> {
509                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
510                    let document_code = bgm.get_element(0)?.get_component(0)?;
511                    (document_code == "220").then(|| {
512                        ValidationIssue::new(
513                            ValidationSeverity::Error,
514                            "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
515                        )
516                        .with_rule_id("DEMO-P001")
517                        .with_segment("BGM")
518                        .with_element_index(0)
519                        .with_suggestion("Use a different BGM document code in this demo pack")
520                    })
521                })());
522            })
523            .with_stateless_rule_fn(|segments, issues| {
524                issues.extend((|| -> Option<ValidationIssue> {
525                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
526                    let reference = bgm.get_element(1)?.get_component(0)?;
527                    (reference == "PO123").then(|| {
528                        ValidationIssue::new(
529                            ValidationSeverity::Warning,
530                            "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
531                        )
532                        .with_rule_id("DEMO-P002")
533                        .with_segment("BGM")
534                        .with_element_index(1)
535                        .with_suggestion("Use a non-reserved reference in this demo pack")
536                    })
537                })());
538            })
539    }
540
541    struct RejectBgm;
542
543    struct WarnBgm;
544
545    impl Validator for RejectBgm {
546        fn validate_batch(
547            &self,
548            segments: &[Segment<'_>],
549            report: &mut ValidationReport,
550            _context: &ValidationRuleContext<'_>,
551        ) {
552            validate_each(segments, report, |segment| {
553                if segment.tag == "BGM" {
554                    return Err(EdifactError::InvalidSegmentForMessage {
555                        tag: "BGM".to_owned(),
556                        message_type: "TEST".to_owned(),
557                        span: segment.tag_span,
558                    });
559                }
560                Ok(())
561            });
562        }
563    }
564
565    impl Validator for WarnBgm {
566        fn validate_batch(
567            &self,
568            segments: &[Segment<'_>],
569            report: &mut ValidationReport,
570            _context: &ValidationRuleContext<'_>,
571        ) {
572            validate_each(segments, report, |segment| {
573                if segment.tag == "BGM" {
574                    return Err(EdifactError::InvalidCodeValue {
575                        tag: "BGM".to_owned(),
576                        element_index: 0,
577                        value: "XXX".to_owned(),
578                        code_list: "1001".to_owned(),
579                        span: segment.span,
580                        suggestion: None,
581                    });
582                }
583                Ok(())
584            });
585        }
586    }
587
588    fn test_segment(tag: &'static str) -> Segment<'static> {
589        Segment {
590            tag,
591            span: crate::Span::new(0, 0),
592            tag_span: crate::Span::new(0, 0),
593            elements: vec![Element::of(&["x"])],
594        }
595    }
596
597    #[test]
598    fn lenient_collects_issues() {
599        let segments = vec![test_segment("UNH"), test_segment("BGM")];
600        let mut report = ValidationReport::default();
601        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
602        assert!(report.has_errors());
603        assert_eq!(report.errors().len(), 1);
604    }
605
606    #[test]
607    fn strict_fails_on_errors() {
608        let segments = vec![test_segment("BGM")];
609        let mut report = ValidationReport::default();
610        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
611        assert!(report.has_errors());
612        assert_eq!(report.errors().len(), 1);
613    }
614
615    #[test]
616    fn context_builder_respects_layer_toggles() {
617        let segments = vec![test_segment("BGM")];
618        let ctx = ValidationContext::builder()
619            .structure(false)
620            .with_validator(ValidationLayer::Structure, RejectBgm)
621            .with_validator(ValidationLayer::CodeList, WarnBgm)
622            .build();
623
624        let report = ctx.validate_lenient(&segments);
625        assert!(!report.has_errors());
626        assert_eq!(report.warnings().len(), 1);
627    }
628
629    #[test]
630    fn context_strict_fails_when_structure_enabled() {
631        let segments = vec![test_segment("BGM")];
632        let ctx = ValidationContext::builder()
633            .with_message_type("ORDERS")
634            .with_validator(ValidationLayer::Structure, RejectBgm)
635            .build();
636
637        assert_eq!(ctx.message_type(), Some("ORDERS"));
638        let result = ctx.validate_strict(&segments);
639        assert!(result.is_err());
640        assert!(result.unwrap_err().has_errors());
641    }
642
643    #[test]
644    fn report_error_applies_default_recovery_hint() {
645        let mut report = ValidationReport::default();
646        report_error(
647            &mut report,
648            EdifactError::InvalidReleaseSequence { offset: 9 },
649        );
650
651        let issue = report
652            .errors()
653            .first()
654            .expect("expected one issue in the report");
655        let hint = issue
656            .suggestion
657            .as_deref()
658            .expect("expected default hint to be set");
659        assert!(hint.contains("Release character"));
660        assert_eq!(issue.error_code(), Some("E019"));
661    }
662
663    #[test]
664    fn missing_required_component_maps_metadata_to_issue() {
665        let mut report = ValidationReport::default();
666        report_error(
667            &mut report,
668            EdifactError::MissingRequiredComponent {
669                tag: "BGM".to_owned(),
670                element_index: 2,
671                component_index: 1,
672            },
673        );
674
675        let issue = report.errors().first().expect("expected one issue");
676        assert_eq!(issue.error_code(), Some("E021"));
677        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
678        assert_eq!(issue.element_index, Some(2));
679        assert_eq!(issue.component_index, Some(1));
680    }
681
682    #[test]
683    fn profile_pack_lenient_collects_profile_rule_issues() {
684        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
685        let segments = crate::from_bytes(input)
686            .collect::<Result<Vec<_>, _>>()
687            .expect("expected parse success");
688
689        let ctx = ValidationContext::builder()
690            .with_profile_pack(demo_orders_profile_pack())
691            .build();
692
693        let report = ctx.validate_lenient(&segments);
694        assert!(report.has_errors());
695        assert!(
696            report
697                .errors()
698                .iter()
699                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
700        );
701        assert!(
702            report
703                .warnings()
704                .iter()
705                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
706        );
707    }
708
709    #[test]
710    fn profile_pack_strict_fails_when_profile_errors_exist() {
711        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
712        let segments = crate::from_bytes(input)
713            .collect::<Result<Vec<_>, _>>()
714            .expect("expected parse success");
715
716        let ctx = ValidationContext::builder()
717            .with_profile_pack(demo_orders_profile_pack())
718            .build();
719        let result = ctx.validate_strict(&segments);
720        assert!(result.is_err());
721        assert!(result.unwrap_err().has_errors());
722    }
723
724    // ── bail_on_first_error ──────────────────────────────────────────────────
725
726    /// A rule that emits two error-severity issues (one per DTM segment).
727    fn two_dtm_errors_rule() -> ProfileRulePack {
728        ProfileRulePack::new("TEST-BAIL")
729            .with_stateless_rule_fn(|segments, issues| {
730                // Rule A: emits one error per DTM segment.
731                for seg in segments.iter().filter(|s| s.tag == "DTM") {
732                    issues.push(
733                        ValidationIssue::new(
734                            ValidationSeverity::Error,
735                            format!("DTM error at offset {}", seg.span.start),
736                        )
737                        .with_rule_id("BAIL-R1")
738                        .with_segment("DTM"),
739                    );
740                }
741            })
742            .with_stateless_rule_fn(|segments, issues| {
743                // Rule B: never fires; used to verify bail skips this rule.
744                for seg in segments.iter().filter(|s| s.tag == "BGM") {
745                    issues.push(
746                        ValidationIssue::new(ValidationSeverity::Error, "BGM error")
747                            .with_rule_id("BAIL-R2")
748                            .with_segment(seg.tag),
749                    );
750                }
751            })
752    }
753
754    #[test]
755    fn bail_on_first_error_fires_at_rule_invocation_granularity() {
756        // Two DTM segments → Rule A emits 2 errors for them.
757        // With bail, Rule B (BGM check) must NOT run.
758        let input =
759            b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
760        let segments = crate::from_bytes(input)
761            .collect::<Result<Vec<_>, _>>()
762            .expect("parse failed");
763
764        let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
765        let ctx = ValidationContext::builder()
766            .with_profile_pack(pack_with_bail)
767            .build();
768        let report = ctx.validate_lenient(&segments);
769
770        // Rule A fires: both DTM errors are in the report (the whole rule invocation
771        // runs to completion before bail is checked).
772        assert_eq!(
773            report
774                .errors()
775                .iter()
776                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
777                .count(),
778            2,
779            "both DTM errors from Rule A should be present"
780        );
781        // Bail fired after Rule A: Rule B (BGM) must be skipped.
782        assert_eq!(
783            report
784                .errors()
785                .iter()
786                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
787                .count(),
788            0,
789            "Rule B should have been skipped by bail"
790        );
791    }
792
793    #[test]
794    fn bail_disabled_runs_all_rules() {
795        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
796        let segments = crate::from_bytes(input)
797            .collect::<Result<Vec<_>, _>>()
798            .expect("parse failed");
799
800        let pack_no_bail = two_dtm_errors_rule(); // bail_on_first_error defaults to false
801        let ctx = ValidationContext::builder()
802            .with_profile_pack(pack_no_bail)
803            .build();
804        let report = ctx.validate_lenient(&segments);
805
806        // Both rules run: one DTM error from Rule A, one BGM error from Rule B.
807        assert_eq!(
808            report
809                .errors()
810                .iter()
811                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
812                .count(),
813            1
814        );
815        assert_eq!(
816            report
817                .errors()
818                .iter()
819                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
820                .count(),
821            1
822        );
823    }
824
825    // ── message_ref in ValidationRuleContext ─────────────────────────────────
826
827    #[test]
828    fn message_ref_is_visible_inside_rule_closure() {
829        let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
830        let segments = crate::from_bytes(input)
831            .collect::<Result<Vec<_>, _>>()
832            .expect("parse failed");
833
834        let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
835            if let Some(mref) = ctx.message_ref {
836                issues.push(
837                    ValidationIssue::new(
838                        ValidationSeverity::Info,
839                        format!("validating message {mref}"),
840                    )
841                    .with_rule_id("CTX-REF"),
842                );
843            }
844        });
845
846        let ctx = ValidationContext::builder()
847            .with_profile_pack(pack)
848            .with_message_ref("MSG001")
849            .build();
850
851        let report = ctx.validate_lenient(&segments);
852        let info = report
853            .infos()
854            .iter()
855            .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
856            .expect("expected info issue from CTX-REF rule");
857        assert!(info.message.contains("MSG001"));
858        // The message_ref is also stamped onto the issue itself.
859        assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
860    }
861}