Skip to main content

edifact_rs/validator/
context.rs

1//! Validation context: `ValidationContext`, `ValidationContextBuilder`, `LayeredValidator`.
2
3use super::pack::ProfileRulePack;
4use super::{EnvelopeValidator, ValidationLayer, ValidationRuleContext, Validator};
5use crate::{OwnedSegment, Segment, ValidationReport, ValidationSeverity};
6use std::any::Any;
7use std::sync::Arc;
8
9pub(super) struct LayeredValidator {
10    pub(super) layer: ValidationLayer,
11    pub(super) validator: Box<dyn Validator + Send + Sync>,
12}
13
14/// Runtime validation context for progressive layered validation.
15///
16/// # Architecture
17///
18/// `edifact-rs` validation is organized into **four independent layers**, each
19/// responsible for a distinct class of checks.  All layers run against the same
20/// segment slice; their issues are collected into a single [`ValidationReport`].
21///
22/// | Layer | [`ValidationLayer`] variant | Default | Type |
23/// |---|---|---|---|
24/// | **Envelope** | `Envelope` | disabled | [`EnvelopeValidator`] |
25/// | **Structure** | `Structure` | enabled | external (e.g. `DirectoryValidator`) |
26/// | **Code-list** | `CodeList` | enabled | external |
27/// | **Profile** | `Profile` | enabled | [`ProfileRulePack`] / `Arc<ProfileRulePack>` |
28///
29/// Validators are run in registration order within each enabled layer.  Layers
30/// themselves have no enforced ordering beyond the order in which they are added
31/// via the builder.
32///
33/// ## Envelope layer
34///
35/// Checks `UNB`/`UNH`/`UNT`/`UNZ` structural invariants: presence, message
36/// count, and segment count.  Enabled by calling
37/// [`ValidationContextBuilder::with_envelope_validation`].  When enabled, the
38/// envelope segments (`UNB`, `UNZ`, `UNG`, `UNE`) are *excluded* from the slice
39/// passed to validators in subsequent layers.
40///
41/// ## Structure layer
42///
43/// Validates segment presence, order, and arity against an EDIFACT directory.
44/// Implemented by `DirectoryValidator` (registered as a `Structure`-layer
45/// validator via [`ValidationContextBuilder::with_validator`]).
46///
47/// ## Code-list layer
48///
49/// Validates DE values against EDIFACT code lists from the directory.  Also
50/// implemented by `DirectoryValidator`.
51///
52/// ## Profile layer
53///
54/// Applies downstream business rules (BDEW AHB / MIG rules, custom constraints)
55/// via [`ProfileRulePack`].  A pack can be scoped to specific EDIFACT message
56/// types (`for_message_type`) and association-assigned codes (`for_release`).
57///
58/// ## Group-aware validation
59///
60/// Validators that implement [`Validator::validate_group_batch`] can additionally
61/// enforce rules scoped to specific segment groups (e.g. "DTM must appear in every
62/// SG5 occurrence").  Call [`validate_lenient_grouped`] with a pre-built
63/// [`SegmentGroupIndexed`] tree to activate both the flat and group passes.
64///
65/// [`SegmentGroupIndexed`]: crate::SegmentGroupIndexed
66/// [`validate_lenient_grouped`]: ValidationContext::validate_lenient_grouped
67///
68/// # Example — building a context
69///
70/// ```rust,ignore
71/// use std::sync::{Arc, LazyLock};
72/// use edifact_rs::{ProfileRulePack, ValidationContext, ValidationLayer};
73///
74/// static ORDERS_PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
75///     Arc::new(
76///         ProfileRulePack::new("ORDERS-MIG-5.5")
77///             .for_message_type("ORDERS")
78///             .require_segment("BGM", "MIG-BGM-M")
79///             .require_segment_in_group("SG2", "NAD", "SG2-NAD-M"),
80///     )
81/// });
82///
83/// let ctx = ValidationContext::builder()
84///     .with_envelope_validation()
85///     .with_profile_pack_arc(Arc::clone(&*ORDERS_PACK))
86///     .build();
87///
88/// let report = ctx.validate_lenient(&segments);
89/// ```
90pub struct ValidationContext {
91    pub(super) validators: Vec<LayeredValidator>,
92    pub(super) envelope_enabled: bool,
93    pub(super) structure_enabled: bool,
94    pub(super) code_list_enabled: bool,
95    pub(super) profile_enabled: bool,
96    /// Stop evaluating all remaining validators as soon as a `Critical`-severity
97    /// issue appears in the report.
98    pub(super) bail_on_first_critical: bool,
99    pub(super) message_type: Option<String>,
100    /// Injected into every emitted `ValidationIssue` when set.
101    pub(super) message_ref: Option<String>,
102    pub(super) metadata: Option<Arc<dyn Any + Send + Sync>>,
103    /// Advisory issues unconditionally appended to every report produced by
104    /// this context — regardless of what segments are validated.
105    ///
106    /// Use [`ValidationContextBuilder::with_static_issue`] to populate.
107    pub(super) static_issues: Vec<crate::ValidationIssue>,
108}
109
110/// Builder for [`ValidationContext`].
111#[must_use = "call `.build()` to produce a `ValidationContext`"]
112pub struct ValidationContextBuilder {
113    pub(super) inner: ValidationContext,
114}
115
116impl Default for ValidationContextBuilder {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl ValidationContextBuilder {
123    /// Create a new context builder.
124    ///
125    /// Structure, code-list, and profile layers are enabled by default.
126    /// The envelope layer is **disabled** by default.
127    pub fn new() -> Self {
128        Self {
129            inner: ValidationContext {
130                validators: Vec::new(),
131                envelope_enabled: false,
132                structure_enabled: true,
133                code_list_enabled: true,
134                profile_enabled: true,
135                bail_on_first_critical: false,
136                message_type: None,
137                message_ref: None,
138                metadata: None,
139                static_issues: Vec::new(),
140            },
141        }
142    }
143
144    /// Attach typed metadata accessible to context-aware profile rules.
145    pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
146        self.inner.metadata = Some(Arc::new(value));
147        self
148    }
149
150    /// Stamp every issue produced by this context with the given message reference.
151    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
152        self.inner.message_ref = Some(message_ref.into());
153        self
154    }
155
156    /// Set message type metadata for downstream validators.
157    pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
158        self.inner.message_type = Some(message_type.into());
159        let configured = self.inner.message_type.as_deref();
160        for layered in &mut self.inner.validators {
161            layered.validator.set_message_type(configured);
162        }
163        self
164    }
165
166    /// Enable/disable structure validators.
167    pub fn structure(mut self, enabled: bool) -> Self {
168        self.inner.structure_enabled = enabled;
169        self
170    }
171
172    /// Enable/disable code-list validators.
173    pub fn code_list(mut self, enabled: bool) -> Self {
174        self.inner.code_list_enabled = enabled;
175        self
176    }
177
178    /// Enable/disable profile validators.
179    pub fn profile(mut self, enabled: bool) -> Self {
180        self.inner.profile_enabled = enabled;
181        self
182    }
183
184    /// Stop all validation as soon as the first `Critical`-severity issue is produced.
185    ///
186    /// When set, [`ValidationContext::validate_lenient`] returns immediately after the
187    /// first `Critical` issue from any validator, skipping all remaining packs and layers.
188    ///
189    /// Default: `false` (collect all issues across all layers).
190    pub fn bail_on_first_critical(mut self, bail: bool) -> Self {
191        self.inner.bail_on_first_critical = bail;
192        self
193    }
194
195    /// Enable/disable envelope layer validators.
196    ///
197    /// Off by default.  Call [`with_envelope_validation`][Self::with_envelope_validation]
198    /// to add the built-in [`EnvelopeValidator`] and enable the layer in one step.
199    pub fn envelope(mut self, enabled: bool) -> Self {
200        self.inner.envelope_enabled = enabled;
201        self
202    }
203
204    /// Add the built-in [`EnvelopeValidator`] and enable the envelope layer.
205    pub fn with_envelope_validation(mut self) -> Self {
206        self.inner.envelope_enabled = true;
207        self.inner.validators.push(LayeredValidator {
208            layer: ValidationLayer::Envelope,
209            validator: Box::new(EnvelopeValidator),
210        });
211        self
212    }
213
214    /// Add a validator assigned to `layer`.
215    pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
216    where
217        V: Validator + 'static,
218    {
219        validator.set_message_type(self.inner.message_type.as_deref());
220        self.inner.validators.push(LayeredValidator {
221            layer,
222            validator: Box::new(validator),
223        });
224        self
225    }
226
227    /// Add a profile rule pack to the profile layer.
228    ///
229    /// The pack's own message-type scoping — set with
230    /// [`ProfileRulePack::for_message_type`] — is what decides whether its rules
231    /// run.  The context's message type does not narrow an unscoped pack.
232    pub fn with_profile_pack(self, pack: ProfileRulePack) -> Self {
233        self.with_profile_pack_inner(pack)
234    }
235
236    fn with_profile_pack_inner(mut self, pack: ProfileRulePack) -> Self {
237        self.inner.validators.push(LayeredValidator {
238            layer: ValidationLayer::Profile,
239            validator: Box::new(pack),
240        });
241        self
242    }
243
244    /// Add a reference-counted profile rule pack to the profile layer.
245    ///
246    /// Unlike [`with_profile_pack`](Self::with_profile_pack), this method stores the pack
247    /// behind an [`Arc`] so context forking (via
248    /// [`ValidationContext::fork_with_message_ref`]) only increments the reference count
249    /// instead of deep-cloning the rule vec.
250    ///
251    /// This is the preferred API for downstream code that caches packs in static
252    /// storage (`LazyLock`, `OnceLock`) and reuses them across many validation calls.
253    ///
254    /// # Example
255    ///
256    /// ```rust,ignore
257    /// use std::sync::{Arc, LazyLock};
258    /// use edifact_rs::{ProfileRulePack, ValidationContext};
259    ///
260    /// static PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
261    ///     Arc::new(ProfileRulePack::new("MIG").require_segment("BGM", "MIG-BGM-M"))
262    /// });
263    ///
264    /// let ctx = ValidationContext::builder()
265    ///     .with_profile_pack_arc(Arc::clone(&*PACK))
266    ///     .build();
267    /// ```
268    pub fn with_profile_pack_arc(mut self, pack: std::sync::Arc<ProfileRulePack>) -> Self {
269        self.inner.validators.push(LayeredValidator {
270            layer: ValidationLayer::Profile,
271            validator: Box::new(pack),
272        });
273        self
274    }
275
276    /// Unconditionally append `issue` to every report produced by this context.
277    ///
278    /// Static issues are emitted on every `validate_*` call — they are not
279    /// evaluated against segments.  This is useful for advisory notices that
280    /// should always be present regardless of message content (e.g. "AHB layer
281    /// is inactive for this message type").
282    pub fn with_static_issue(mut self, issue: crate::ValidationIssue) -> Self {
283        self.inner.static_issues.push(issue);
284        self
285    }
286
287    /// Finalize builder and create context.
288    #[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
289    pub fn build(self) -> ValidationContext {
290        self.inner
291    }
292}
293
294impl ValidationContext {
295    /// Start building a validation context.
296    pub fn builder() -> ValidationContextBuilder {
297        ValidationContextBuilder::new()
298    }
299
300    /// Execute validators in lenient mode for enabled layers.
301    pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
302        self.validate_with_context(segments, &self.build_rule_context())
303    }
304
305    /// Execute flat + group-aware validators in lenient mode.
306    ///
307    /// This method runs the full flat validation pass (same as
308    /// [`validate_lenient`](Self::validate_lenient)) **and** then runs the
309    /// group-aware pass by calling [`Validator::validate_group_batch`] on every
310    /// validator.  Validators without group rules treat `validate_group_batch`
311    /// as a no-op, so this is safe to call for any context.
312    ///
313    /// # When to use
314    ///
315    /// Use this method when you have already grouped your segments with
316    /// [`group_segments_indexed`][crate::group_segments_indexed] or
317    /// [`group_owned_segments_indexed`][crate::group_owned_segments_indexed] and
318    /// want group-presence or cross-group rules (via
319    /// [`ProfileRulePack::with_scoped_group_rule_fn`][crate::ProfileRulePack::with_scoped_group_rule_fn])
320    /// to fire.
321    ///
322    /// # Example
323    ///
324    /// ```rust,ignore
325    /// use edifact_rs::{group_segments_indexed, ValidationContext};
326    /// use edifact_rs::group::GroupDef;
327    ///
328    /// static SCHEMA: &[GroupDef] = &[
329    ///     GroupDef { name: "SG5", trigger: "LOC", children: &[] },
330    /// ];
331    ///
332    /// let tree = group_segments_indexed(&segments, SCHEMA, "ROOT");
333    /// let pack = ProfileRulePack::new("AHB")
334    ///     .require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
335    /// let ctx = ValidationContext::builder().with_profile_pack(pack).build();
336    ///
337    /// let report = ctx.validate_lenient_grouped(&tree, &segments);
338    /// ```
339    pub fn validate_lenient_grouped(
340        &self,
341        root: &crate::group::SegmentGroupIndexed,
342        segments: &[Segment<'_>],
343    ) -> ValidationReport {
344        let base_ctx = self.build_rule_context();
345        // Phase 1: flat validation.
346        let mut report = self.validate_with_context(segments, &base_ctx);
347        // Phase 2: group-aware validation with pre-extracted UNH message type.
348        let unh_mt = segments
349            .iter()
350            .find(|s| s.tag == "UNH")
351            .and_then(|s| s.get_element(1))
352            .and_then(|e| e.get_component(0));
353        let ctx_with_type;
354        let group_ctx: &ValidationRuleContext<'_> = if let Some(mt) = unh_mt {
355            ctx_with_type = ValidationRuleContext {
356                metadata: base_ctx.metadata,
357                message_ref: base_ctx.message_ref,
358                message_type: Some(mt),
359            };
360            &ctx_with_type
361        } else {
362            &base_ctx
363        };
364        self.run_group_pass(root, segments, &mut report, group_ctx);
365        report
366    }
367
368    /// Execute flat + group-aware validators in strict mode.
369    pub fn validate_strict_grouped(
370        &self,
371        root: &crate::group::SegmentGroupIndexed,
372        segments: &[Segment<'_>],
373    ) -> Result<ValidationReport, ValidationReport> {
374        self.validate_lenient_grouped(root, segments).result()
375    }
376
377    /// Execute flat + group-aware validators against owned segments in lenient mode.
378    pub fn validate_lenient_grouped_owned(
379        &self,
380        root: &crate::group::SegmentGroupIndexed,
381        segments: &[crate::OwnedSegment],
382    ) -> ValidationReport {
383        let base_ctx = self.build_rule_context();
384        // Phase 1: flat validation.
385        let mut report = self.validate_with_context_owned(segments, &base_ctx);
386        // Phase 2: group-aware validation — skip early if no validator has group
387        // rules, avoiding the O(n) borrowed-segment allocation entirely.
388        if !self
389            .validators
390            .iter()
391            .any(|lv| self.layer_enabled(lv.layer) && lv.validator.has_group_rules())
392        {
393            return report;
394        }
395        let borrowed: Vec<Segment<'_>> = segments.iter().map(|s| s.as_borrowed()).collect();
396        let unh_mt = borrowed
397            .iter()
398            .find(|s| s.tag == "UNH")
399            .and_then(|s| s.get_element(1))
400            .and_then(|e| e.get_component(0));
401        let ctx_with_type;
402        let group_ctx: &ValidationRuleContext<'_> = if let Some(mt) = unh_mt {
403            ctx_with_type = ValidationRuleContext {
404                metadata: base_ctx.metadata,
405                message_ref: base_ctx.message_ref,
406                message_type: Some(mt),
407            };
408            &ctx_with_type
409        } else {
410            &base_ctx
411        };
412        self.run_group_pass(root, &borrowed, &mut report, group_ctx);
413        report
414    }
415
416    /// Execute flat + group-aware validators against owned segments in strict mode.
417    pub fn validate_strict_grouped_owned(
418        &self,
419        root: &crate::group::SegmentGroupIndexed,
420        segments: &[crate::OwnedSegment],
421    ) -> Result<ValidationReport, ValidationReport> {
422        self.validate_lenient_grouped_owned(root, segments).result()
423    }
424
425    /// Phase-2 group pass: call `validate_group_batch` on each enabled validator.
426    fn run_group_pass(
427        &self,
428        root: &crate::group::SegmentGroupIndexed,
429        segments: &[Segment<'_>],
430        report: &mut ValidationReport,
431        context: &ValidationRuleContext<'_>,
432    ) {
433        // Short-circuit: skip the entire DFS tree walk when no enabled validator
434        // has group rules.  This avoids the O(n) borrowed-segment allocation in
435        // `validate_lenient_grouped_owned` for the common case where the context
436        // only has flat (envelope/structure/code-list) validators.
437        if !self
438            .validators
439            .iter()
440            .any(|lv| self.layer_enabled(lv.layer) && lv.validator.has_group_rules())
441        {
442            return;
443        }
444        for lv in &self.validators {
445            if !self.layer_enabled(lv.layer) {
446                continue;
447            }
448            lv.validator
449                .validate_group_batch(root, segments, report, context);
450            if self.bail_on_first_critical && report.has_critical_errors() {
451                break;
452            }
453        }
454    }
455
456    /// Execute validators with per-call typed metadata.
457    ///
458    /// `message_type` is set to `None` here; the concrete validation method
459    /// (`validate_with_context`) re-extracts the message type from the `UNH`
460    /// segment, so there is no information loss.
461    pub fn validate_lenient_with<T: Any + Send + Sync>(
462        &self,
463        segments: &[Segment<'_>],
464        value: &T,
465    ) -> ValidationReport {
466        let ctx = ValidationRuleContext {
467            metadata: Some(value as &(dyn Any + Send + Sync)),
468            message_ref: self.message_ref.as_deref(),
469            message_type: None,
470        };
471        self.validate_with_context(segments, &ctx)
472    }
473
474    /// Execute validators in strict mode for enabled layers.
475    pub fn validate_strict(
476        &self,
477        segments: &[Segment<'_>],
478    ) -> Result<ValidationReport, ValidationReport> {
479        self.validate_lenient(segments).result()
480    }
481
482    /// Execute validators in strict mode with per-call typed metadata.
483    pub fn validate_strict_with<T: Any + Send + Sync>(
484        &self,
485        segments: &[Segment<'_>],
486        value: &T,
487    ) -> Result<ValidationReport, ValidationReport> {
488        self.validate_lenient_with(segments, value).result()
489    }
490
491    /// Execute validators in lenient mode against an owned-segment slice.
492    pub fn validate_lenient_owned(&self, segments: &[OwnedSegment]) -> ValidationReport {
493        self.validate_with_context_owned(segments, &self.build_rule_context())
494    }
495
496    fn build_rule_context(&self) -> ValidationRuleContext<'_> {
497        self.metadata
498            .as_ref()
499            .map(|arc| ValidationRuleContext {
500                metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
501                message_ref: self.message_ref.as_deref(),
502                message_type: None,
503            })
504            .unwrap_or_else(|| ValidationRuleContext {
505                metadata: None,
506                message_ref: self.message_ref.as_deref(),
507                message_type: None,
508            })
509    }
510
511    fn validate_with_context_owned(
512        &self,
513        segments: &[OwnedSegment],
514        context: &ValidationRuleContext<'_>,
515    ) -> ValidationReport {
516        let mut report = ValidationReport::default();
517        // Pre-extract UNH message type once (F-017).
518        let unh_message_type: Option<String> = segments
519            .iter()
520            .find(|s| s.tag == "UNH")
521            .and_then(|s| s.component_str(1, 0))
522            .map(str::to_owned);
523        let ctx_with_type;
524        let effective_ctx: &ValidationRuleContext<'_> = if let Some(ref mt) = unh_message_type {
525            ctx_with_type = ValidationRuleContext {
526                metadata: context.metadata,
527                message_ref: context.message_ref,
528                message_type: Some(mt.as_str()),
529            };
530            &ctx_with_type
531        } else {
532            context
533        };
534        let mut full_borrowed: Option<Vec<Segment<'_>>> = None;
535        let mut filtered_borrowed: Option<Vec<Segment<'_>>> = None;
536        // Decided once, up front.  Deriving this from a flag flipped as the loop
537        // walks `self.validators` made the filtering depend on *registration*
538        // order, so moving `.with_envelope_validation()` in the builder chain
539        // silently changed which segments later layers saw.
540        let envelope_active = self.envelope_layer_active();
541
542        for lv in &self.validators {
543            if !self.layer_enabled(lv.layer) {
544                continue;
545            }
546            if lv.layer == ValidationLayer::Envelope {
547                let active = full_borrowed
548                    .get_or_insert_with(|| segments.iter().map(|s| s.as_borrowed()).collect());
549                lv.validator
550                    .validate_batch(active, &mut report, effective_ctx);
551            } else if envelope_active {
552                let active = filtered_borrowed.get_or_insert_with(|| {
553                    segments
554                        .iter()
555                        .filter(|s| !matches!(s.tag.as_str(), "UNB" | "UNZ" | "UNG" | "UNE"))
556                        .map(|s| s.as_borrowed())
557                        .collect()
558                });
559                lv.validator
560                    .validate_batch(active, &mut report, effective_ctx);
561            } else {
562                let active = full_borrowed
563                    .get_or_insert_with(|| segments.iter().map(|s| s.as_borrowed()).collect());
564                lv.validator
565                    .validate_batch(active, &mut report, effective_ctx);
566            }
567            if self.bail_on_first_critical && report.has_critical_errors() {
568                break;
569            }
570        }
571
572        if let Some(ref msg_ref) = self.message_ref {
573            for issue in report
574                .errors
575                .iter_mut()
576                .chain(report.warnings.iter_mut())
577                .chain(report.infos.iter_mut())
578            {
579                if issue.message_ref.is_none() {
580                    issue.message_ref = Some(msg_ref.clone());
581                }
582            }
583        }
584        for issue in &self.static_issues {
585            match issue.severity {
586                ValidationSeverity::Critical | ValidationSeverity::Error => {
587                    report.add_error(issue.clone());
588                }
589                ValidationSeverity::Warning => {
590                    report.warnings.push(issue.clone());
591                }
592                ValidationSeverity::Info => {
593                    report.infos.push(issue.clone());
594                }
595            }
596        }
597        report
598    }
599
600    /// Execute validators in strict mode against an owned-segment slice.
601    pub fn validate_strict_owned(
602        &self,
603        segments: &[OwnedSegment],
604    ) -> Result<ValidationReport, ValidationReport> {
605        self.validate_lenient_owned(segments).result()
606    }
607
608    fn validate_with_context(
609        &self,
610        segments: &[Segment<'_>],
611        context: &ValidationRuleContext<'_>,
612    ) -> ValidationReport {
613        let mut report = ValidationReport::default();
614        // Pre-extract UNH message type once (F-017).
615        let unh_message_type = segments
616            .iter()
617            .find(|s| s.tag == "UNH")
618            .and_then(|s| s.get_element(1))
619            .and_then(|e| e.get_component(0));
620        let ctx_with_type;
621        let effective_ctx: &ValidationRuleContext<'_> = if let Some(mt) = unh_message_type {
622            ctx_with_type = ValidationRuleContext {
623                metadata: context.metadata,
624                message_ref: context.message_ref,
625                message_type: Some(mt),
626            };
627            &ctx_with_type
628        } else {
629            context
630        };
631        let mut filtered: Option<Vec<Segment<'_>>> = None;
632        // See the owned path: computed once so filtering is independent of the
633        // order in which validators were registered.
634        let envelope_active = self.envelope_layer_active();
635
636        for lv in &self.validators {
637            if !self.layer_enabled(lv.layer) {
638                continue;
639            }
640            if lv.layer == ValidationLayer::Envelope {
641                lv.validator
642                    .validate_batch(segments, &mut report, effective_ctx);
643            } else {
644                let active: &[Segment<'_>] = if envelope_active {
645                    match envelope_interior(segments) {
646                        // Common case: UNB/UNZ bracket the message and no
647                        // UNG/UNE appear inside, so a sub-slice suffices and no
648                        // segment has to be deep-cloned.
649                        Some(interior) => interior,
650                        None => filtered.get_or_insert_with(|| {
651                            segments
652                                .iter()
653                                .filter(|s| !matches!(s.tag, "UNB" | "UNZ" | "UNG" | "UNE"))
654                                .cloned()
655                                .collect()
656                        }),
657                    }
658                } else {
659                    segments
660                };
661                lv.validator
662                    .validate_batch(active, &mut report, effective_ctx);
663            }
664            if self.bail_on_first_critical && report.has_critical_errors() {
665                break;
666            }
667        }
668
669        if let Some(ref msg_ref) = self.message_ref {
670            for issue in report
671                .errors
672                .iter_mut()
673                .chain(report.warnings.iter_mut())
674                .chain(report.infos.iter_mut())
675            {
676                if issue.message_ref.is_none() {
677                    issue.message_ref = Some(msg_ref.clone());
678                }
679            }
680        }
681        // Append static advisory issues unconditionally.
682        for issue in &self.static_issues {
683            match issue.severity {
684                ValidationSeverity::Critical | ValidationSeverity::Error => {
685                    report.add_error(issue.clone());
686                }
687                ValidationSeverity::Warning => {
688                    report.warnings.push(issue.clone());
689                }
690                ValidationSeverity::Info => {
691                    report.infos.push(issue.clone());
692                }
693            }
694        }
695        report
696    }
697
698    /// Message type metadata associated with this context, if provided.
699    pub fn message_type(&self) -> Option<&str> {
700        self.message_type.as_deref()
701    }
702
703    /// Message reference (`UNH` element 0) associated with this context, if provided.
704    pub fn message_ref(&self) -> Option<&str> {
705        self.message_ref.as_deref()
706    }
707
708    /// Create a child context that inherits all rules and configuration from `self`
709    /// but is scoped to a specific message reference (UNH DE 0062).
710    ///
711    /// Issues produced by the child context are automatically stamped with
712    /// `message_ref`, making it easy to correlate findings in a multi-message
713    /// interchange back to the originating `UNH`/`UNT` envelope.
714    ///
715    /// # Example
716    ///
717    /// ```rust,ignore
718    /// let base_ctx = ValidationContext::builder()
719    ///     .with_profile_pack(mig_pack)
720    ///     .build();
721    ///
722    /// for (ref_no, message_segments) in messages {
723    ///     let child = base_ctx.fork_with_message_ref(&ref_no);
724    ///     let report = child.validate_lenient(&message_segments);
725    /// }
726    /// ```
727    pub fn fork_with_message_ref(&self, message_ref: impl Into<String>) -> Self {
728        let validators: Vec<LayeredValidator> = self
729            .validators
730            .iter()
731            .filter_map(|lv| {
732                lv.validator.fork().map(|forked| LayeredValidator {
733                    layer: lv.layer,
734                    validator: forked,
735                })
736            })
737            .collect();
738        // Count how many validators were excluded (non-forkable).
739        let excluded_count = self.validators.len() - validators.len();
740
741        let mut static_issues = self.static_issues.clone();
742        if excluded_count > 0 {
743            static_issues.push(
744                crate::ValidationIssue::new(
745                    crate::ValidationSeverity::Info,
746                    format!(
747                        "{excluded_count} validator(s) excluded from forked context \
748                         because fork() returned None; all their rules (flat and \
749                         group-pass) will not run for this message",
750                    ),
751                )
752                .with_rule_id("edifact-rs::fork::excluded-validator"),
753            );
754        }
755
756        Self {
757            validators,
758            envelope_enabled: self.envelope_enabled,
759            structure_enabled: self.structure_enabled,
760            code_list_enabled: self.code_list_enabled,
761            profile_enabled: self.profile_enabled,
762            bail_on_first_critical: self.bail_on_first_critical,
763            message_type: self.message_type.clone(),
764            message_ref: Some(message_ref.into()),
765            metadata: self.metadata.clone(),
766            static_issues,
767        }
768    }
769
770    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
771        match layer {
772            ValidationLayer::Envelope => self.envelope_enabled,
773            ValidationLayer::Structure => self.structure_enabled,
774            ValidationLayer::CodeList => self.code_list_enabled,
775            ValidationLayer::Profile => self.profile_enabled,
776        }
777    }
778
779    /// Whether an enabled envelope-layer validator is registered.
780    ///
781    /// Determines whether envelope segments are hidden from later layers.  It is
782    /// a property of the context as a whole, not of how far the validator loop
783    /// has progressed.
784    fn envelope_layer_active(&self) -> bool {
785        self.envelope_enabled
786            && self
787                .validators
788                .iter()
789                .any(|lv| lv.layer == ValidationLayer::Envelope)
790    }
791}
792
793/// Return the message body as a sub-slice when the envelope segments form a
794/// clean `UNB` … `UNZ` bracket with no functional groups inside.
795///
796/// Returns `None` when the caller must fall back to filter-and-clone (functional
797/// groups present, or the interchange is not bracketed as expected).
798fn envelope_interior<'s, 'a>(segments: &'s [Segment<'a>]) -> Option<&'s [Segment<'a>]> {
799    let (first, last) = (segments.first()?, segments.last()?);
800    if segments.len() < 2 || first.tag != "UNB" || last.tag != "UNZ" {
801        return None;
802    }
803    let interior = &segments[1..segments.len() - 1];
804    if interior
805        .iter()
806        .any(|s| matches!(s.tag, "UNB" | "UNZ" | "UNG" | "UNE"))
807    {
808        return None;
809    }
810    Some(interior)
811}