Skip to main content

edifact_rs/validator/
context.rs

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