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