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::{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/// Runs the four validation layers over one segment slice, collecting every
17/// issue into a single [`ValidationReport`].
18///
19/// | Layer | [`ValidationLayer`] | Default | Checks | Provided by |
20/// |---|---|---|---|---|
21/// | Envelope | `Envelope` | off | `UNB`/`UNH`/`UNT`/`UNZ` structure and counts | [`EnvelopeValidator`] |
22/// | Structure | `Structure` | on | segment presence, order, arity | `DirectoryValidator` |
23/// | Code-list | `CodeList` | on | DE values against the directory's code lists | `DirectoryValidator` |
24/// | Profile | `Profile` | on | partner and industry business rules | [`ProfileRulePack`] |
25///
26/// Validators run in registration order within a layer; layers have no ordering
27/// beyond that. With the envelope layer on, `UNB`/`UNZ`/`UNG`/`UNE` are excluded
28/// from the slice later layers see.
29///
30/// A pack can be scoped by message type (`for_message_type`) and
31/// association-assigned code (`for_release`).
32///
33/// # Group-aware validation
34///
35/// [`validate_grouped`][Self::validate_grouped] runs the flat pass and then a
36/// group pass over a [`SegmentGroupIndexed`][crate::SegmentGroupIndexed] tree,
37/// so rules scoped to a segment group — "DTM must appear in every SG5" — can
38/// fire.
39///
40/// # Example — building a context
41///
42/// ```rust,ignore
43/// use std::sync::{Arc, LazyLock};
44/// use edifact_rs::{ProfileRulePack, ValidationContext, ValidationLayer};
45///
46/// static ORDERS_PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
47///     Arc::new(
48///         ProfileRulePack::new("ORDERS-MIG-5.5")
49///             .for_message_type("ORDERS")
50///             .require_segment("BGM", "MIG-BGM-M")
51///             .require_segment_in_group("SG2", "NAD", "SG2-NAD-M"),
52///     )
53/// });
54///
55/// let ctx = ValidationContext::builder()
56///     .with_envelope_validation()
57///     .with_profile_pack_arc(Arc::clone(&*ORDERS_PACK))
58///     .build();
59///
60/// let report = ctx.validate(&segments);
61/// ```
62pub struct ValidationContext {
63    pub(super) validators: Vec<LayeredValidator>,
64    pub(super) envelope_enabled: bool,
65    pub(super) structure_enabled: bool,
66    pub(super) code_list_enabled: bool,
67    pub(super) profile_enabled: bool,
68    /// Stop evaluating all remaining validators as soon as a `Critical`-severity
69    /// issue appears in the report.
70    pub(super) bail_on_first_critical: bool,
71    pub(super) message_type: Option<String>,
72    /// Injected into every emitted `ValidationIssue` when set.
73    pub(super) message_ref: Option<String>,
74    pub(super) metadata: Option<Arc<dyn Any + Send + Sync>>,
75    /// Advisory issues unconditionally appended to every report produced by
76    /// this context — regardless of what segments are validated.
77    ///
78    /// Use [`ValidationContextBuilder::with_static_issue`] to populate.
79    pub(super) static_issues: Vec<crate::ValidationIssue>,
80}
81
82/// Builder for [`ValidationContext`].
83#[must_use = "call `.build()` to produce a `ValidationContext`"]
84pub struct ValidationContextBuilder {
85    pub(super) inner: ValidationContext,
86}
87
88impl Default for ValidationContextBuilder {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl ValidationContextBuilder {
95    /// Create a new context builder.
96    ///
97    /// Structure, code-list, and profile layers are enabled by default.
98    /// The envelope layer is **disabled** by default.
99    pub fn new() -> Self {
100        Self {
101            inner: ValidationContext {
102                validators: Vec::new(),
103                envelope_enabled: false,
104                structure_enabled: true,
105                code_list_enabled: true,
106                profile_enabled: true,
107                bail_on_first_critical: false,
108                message_type: None,
109                message_ref: None,
110                metadata: None,
111                static_issues: Vec::new(),
112            },
113        }
114    }
115
116    /// Attach typed metadata accessible to context-aware profile rules.
117    pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
118        self.inner.metadata = Some(Arc::new(value));
119        self
120    }
121
122    /// Stamp every issue produced by this context with the given message reference.
123    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
124        self.inner.message_ref = Some(message_ref.into());
125        self
126    }
127
128    /// Set message type metadata for downstream validators.
129    pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
130        self.inner.message_type = Some(message_type.into());
131        let configured = self.inner.message_type.as_deref();
132        for layered in &mut self.inner.validators {
133            layered.validator.set_message_type(configured);
134        }
135        self
136    }
137
138    /// Enable/disable structure validators.
139    pub fn structure(mut self, enabled: bool) -> Self {
140        self.inner.structure_enabled = enabled;
141        self
142    }
143
144    /// Enable/disable code-list validators.
145    pub fn code_list(mut self, enabled: bool) -> Self {
146        self.inner.code_list_enabled = enabled;
147        self
148    }
149
150    /// Enable/disable profile validators.
151    pub fn profile(mut self, enabled: bool) -> Self {
152        self.inner.profile_enabled = enabled;
153        self
154    }
155
156    /// Stop validating once a `Critical`-severity issue has appeared.
157    ///
158    /// The check happens **between validators**, not between issues: the
159    /// validator that raised the `Critical` still finishes and contributes
160    /// everything it found, and every validator after it — in any layer — is
161    /// skipped. Bailing mid-validator would mean a report whose contents depend
162    /// on the order rules happen to run in.
163    ///
164    /// Default: `false` (run every enabled layer and collect all issues).
165    pub fn bail_on_first_critical(mut self, bail: bool) -> Self {
166        self.inner.bail_on_first_critical = bail;
167        self
168    }
169
170    /// Enable/disable envelope layer validators.
171    ///
172    /// Off by default.  Call [`with_envelope_validation`][Self::with_envelope_validation]
173    /// to add the built-in [`EnvelopeValidator`] and enable the layer in one step.
174    pub fn envelope(mut self, enabled: bool) -> Self {
175        self.inner.envelope_enabled = enabled;
176        self
177    }
178
179    /// Add the built-in [`EnvelopeValidator`] and enable the envelope layer.
180    pub fn with_envelope_validation(mut self) -> Self {
181        self.inner.envelope_enabled = true;
182        self.inner.validators.push(LayeredValidator {
183            layer: ValidationLayer::Envelope,
184            validator: Box::new(EnvelopeValidator),
185        });
186        self
187    }
188
189    /// Check every value against the repertoire the interchange declares, and
190    /// enable the envelope layer.
191    ///
192    /// Adds a [`CharsetValidator`] reading `UNB` S001 DE 0001. See
193    /// [`with_charset_validation_for`][Self::with_charset_validation_for] to pin
194    /// a repertoire instead of reading it from the envelope.
195    pub fn with_charset_validation(mut self) -> Self {
196        self.inner.envelope_enabled = true;
197        self.inner.validators.push(LayeredValidator {
198            layer: ValidationLayer::Envelope,
199            validator: Box::new(CharsetValidator::from_envelope()),
200        });
201        self
202    }
203
204    /// Check every value against a fixed repertoire, and enable the envelope layer.
205    ///
206    /// Use for message-level slices that carry no `UNB`, or to hold a partner to
207    /// a stricter repertoire than the one they declare.
208    pub fn with_charset_validation_for(mut self, charset: crate::Charset) -> Self {
209        self.inner.envelope_enabled = true;
210        self.inner.validators.push(LayeredValidator {
211            layer: ValidationLayer::Envelope,
212            validator: Box::new(CharsetValidator::with_charset(charset)),
213        });
214        self
215    }
216
217    /// Check the directory-independent ISO 9735-1 syntax rules, and enable the
218    /// envelope layer.
219    ///
220    /// See [`SyntaxValidator`][crate::SyntaxValidator] for the exact rules. They
221    /// apply to any interchange from any partner in any directory, so this needs
222    /// no configuration and is worth enabling wherever the envelope layer is on.
223    pub fn with_syntax_validation(mut self) -> Self {
224        self.inner.envelope_enabled = true;
225        self.inner.validators.push(LayeredValidator {
226            layer: ValidationLayer::Envelope,
227            validator: Box::new(crate::validator::SyntaxValidator),
228        });
229        self
230    }
231
232    /// Add a validator assigned to `layer`.
233    pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
234    where
235        V: Validator + 'static,
236    {
237        validator.set_message_type(self.inner.message_type.as_deref());
238        self.inner.validators.push(LayeredValidator {
239            layer,
240            validator: Box::new(validator),
241        });
242        self
243    }
244
245    /// Add a profile rule pack to the profile layer.
246    ///
247    /// The pack's own message-type scoping — set with
248    /// [`ProfileRulePack::for_message_type`] — is what decides whether its rules
249    /// run.  The context's message type does not narrow an unscoped pack.
250    pub fn with_profile_pack(self, pack: ProfileRulePack) -> Self {
251        self.with_profile_pack_inner(pack)
252    }
253
254    fn with_profile_pack_inner(mut self, pack: ProfileRulePack) -> Self {
255        self.inner.validators.push(LayeredValidator {
256            layer: ValidationLayer::Profile,
257            validator: Box::new(pack),
258        });
259        self
260    }
261
262    /// Add a reference-counted profile rule pack to the profile layer.
263    ///
264    /// Unlike [`with_profile_pack`](Self::with_profile_pack), this method stores the pack
265    /// behind an [`Arc`] so context forking (via
266    /// [`ValidationContext::fork_with_message_ref`]) only increments the reference count
267    /// instead of deep-cloning the rule vec.
268    ///
269    /// This is the preferred API for downstream code that caches packs in static
270    /// storage (`LazyLock`, `OnceLock`) and reuses them across many validation calls.
271    ///
272    /// # Example
273    ///
274    /// ```rust,ignore
275    /// use std::sync::{Arc, LazyLock};
276    /// use edifact_rs::{ProfileRulePack, ValidationContext};
277    ///
278    /// static PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
279    ///     Arc::new(ProfileRulePack::new("MIG").require_segment("BGM", "MIG-BGM-M"))
280    /// });
281    ///
282    /// let ctx = ValidationContext::builder()
283    ///     .with_profile_pack_arc(Arc::clone(&*PACK))
284    ///     .build();
285    /// ```
286    pub fn with_profile_pack_arc(mut self, pack: std::sync::Arc<ProfileRulePack>) -> Self {
287        self.inner.validators.push(LayeredValidator {
288            layer: ValidationLayer::Profile,
289            validator: Box::new(pack),
290        });
291        self
292    }
293
294    /// Unconditionally append `issue` to every report produced by this context.
295    ///
296    /// Static issues are emitted on every `validate_*` call — they are not
297    /// evaluated against segments.  This is useful for advisory notices that
298    /// should always be present regardless of message content (e.g. "the profile layer
299    /// is inactive for this message type").
300    pub fn with_static_issue(mut self, issue: crate::ValidationIssue) -> Self {
301        self.inner.static_issues.push(issue);
302        self
303    }
304
305    /// Finalize builder and create context.
306    #[must_use = "call `.validate()` on the resulting context"]
307    pub fn build(self) -> ValidationContext {
308        self.inner
309    }
310}
311
312impl ValidationContext {
313    /// Start building a validation context.
314    pub fn builder() -> ValidationContextBuilder {
315        ValidationContextBuilder::new()
316    }
317
318    /// Execute validators in lenient mode for enabled layers.
319    pub fn validate(&self, segments: &[Segment<'_>]) -> ValidationReport {
320        self.validate_with_context(segments, &self.build_rule_context())
321    }
322
323    /// Execute flat + group-aware validators in lenient mode.
324    ///
325    /// This method runs the full flat validation pass (same as
326    /// [`validate_lenient`](Self::validate)) **and** then runs the
327    /// group-aware pass by calling [`Validator::validate_group_batch`] on every
328    /// validator.  Validators without group rules treat `validate_group_batch`
329    /// as a no-op, so this is safe to call for any context.
330    ///
331    /// # When to use
332    ///
333    /// Use this method when you have already grouped your segments with
334    /// [`group_segments_indexed`][crate::group_segments_indexed] or
335    /// [`group_segments_indexed`][crate::group_segments_indexed] and
336    /// want group-presence or cross-group rules (via
337    /// [`ProfileRulePack::with_scoped_group_rule_fn`][crate::ProfileRulePack::with_scoped_group_rule_fn])
338    /// to fire.
339    ///
340    /// # Example
341    ///
342    /// ```rust,ignore
343    /// use edifact_rs::{group_segments_indexed, ValidationContext};
344    /// use edifact_rs::group::GroupDef;
345    ///
346    /// static SCHEMA: &[GroupDef] = &[GroupDef::new("SG5", "LOC")];
347    ///
348    /// let tree = group_segments_indexed(&segments, SCHEMA, "ROOT");
349    /// let pack = ProfileRulePack::new("PROFILE")
350    ///     .require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
351    /// let ctx = ValidationContext::builder().with_profile_pack(pack).build();
352    ///
353    /// let report = ctx.validate_grouped(&tree, &segments);
354    /// ```
355    pub fn validate_grouped(
356        &self,
357        root: &crate::group::SegmentGroupIndexed<'_>,
358        segments: &[Segment<'_>],
359    ) -> ValidationReport {
360        let base_ctx = self.build_rule_context();
361        // Phase 1: flat validation.
362        let mut report = self.validate_with_context(segments, &base_ctx);
363        // Phase 2: group-aware validation with pre-extracted UNH message type.
364        let unh_mt = segments
365            .iter()
366            .find(|s| s.tag == "UNH")
367            .and_then(|s| s.get_element(1))
368            .and_then(|e| e.get_component(0));
369        let ctx_with_type;
370        let group_ctx: &ValidationRuleContext<'_> = if let Some(mt) = unh_mt {
371            ctx_with_type = ValidationRuleContext {
372                metadata: base_ctx.metadata,
373                message_ref: base_ctx.message_ref,
374                message_type: Some(mt),
375            };
376            &ctx_with_type
377        } else {
378            &base_ctx
379        };
380        self.run_group_pass(root, segments, &mut report, group_ctx);
381        report
382    }
383
384    /// Phase-2 group pass: call `validate_group_batch` on each enabled validator.
385    fn run_group_pass(
386        &self,
387        root: &crate::group::SegmentGroupIndexed<'_>,
388        segments: &[Segment<'_>],
389        report: &mut ValidationReport,
390        context: &ValidationRuleContext<'_>,
391    ) {
392        // Short-circuit: skip the entire DFS tree walk when no enabled validator
393        // has group rules.  This avoids the O(n) borrowed-segment allocation in
394        // `validate_lenient_grouped_owned` for the common case where the context
395        // only has flat (envelope/structure/code-list) validators.
396        if !self
397            .validators
398            .iter()
399            .any(|lv| self.layer_enabled(lv.layer) && lv.validator.has_group_rules())
400        {
401            return;
402        }
403        for lv in &self.validators {
404            if !self.layer_enabled(lv.layer) {
405                continue;
406            }
407            lv.validator
408                .validate_group_batch(root, segments, report, context);
409            if self.bail_on_first_critical && report.has_critical_errors() {
410                break;
411            }
412        }
413    }
414
415    /// Execute validators with per-call typed metadata.
416    ///
417    /// `message_type` is set to `None` here; the concrete validation method
418    /// (`validate_with_context`) re-extracts the message type from the `UNH`
419    /// segment, so there is no information loss.
420    pub fn validate_with<T: Any + Send + Sync>(
421        &self,
422        segments: &[Segment<'_>],
423        value: &T,
424    ) -> ValidationReport {
425        let ctx = ValidationRuleContext {
426            metadata: Some(value as &(dyn Any + Send + Sync)),
427            message_ref: self.message_ref.as_deref(),
428            message_type: None,
429        };
430        self.validate_with_context(segments, &ctx)
431    }
432
433    fn build_rule_context(&self) -> ValidationRuleContext<'_> {
434        self.metadata
435            .as_ref()
436            .map(|arc| ValidationRuleContext {
437                metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
438                message_ref: self.message_ref.as_deref(),
439                message_type: None,
440            })
441            .unwrap_or_else(|| ValidationRuleContext {
442                metadata: None,
443                message_ref: self.message_ref.as_deref(),
444                message_type: None,
445            })
446    }
447
448    fn validate_with_context(
449        &self,
450        segments: &[Segment<'_>],
451        context: &ValidationRuleContext<'_>,
452    ) -> ValidationReport {
453        let mut report = ValidationReport::default();
454        // Pre-extract UNH message type once (F-017).
455        let unh_message_type = segments
456            .iter()
457            .find(|s| s.tag == "UNH")
458            .and_then(|s| s.get_element(1))
459            .and_then(|e| e.get_component(0));
460        let ctx_with_type;
461        let effective_ctx: &ValidationRuleContext<'_> = if let Some(mt) = unh_message_type {
462            ctx_with_type = ValidationRuleContext {
463                metadata: context.metadata,
464                message_ref: context.message_ref,
465                message_type: Some(mt),
466            };
467            &ctx_with_type
468        } else {
469            context
470        };
471        let mut filtered: Option<Vec<Segment<'_>>> = None;
472        // See the owned path: computed once so filtering is independent of the
473        // order in which validators were registered.
474        let envelope_active = self.envelope_layer_active();
475
476        for lv in &self.validators {
477            if !self.layer_enabled(lv.layer) {
478                continue;
479            }
480            if lv.layer == ValidationLayer::Envelope {
481                lv.validator
482                    .validate_batch(segments, &mut report, effective_ctx);
483            } else {
484                let active: &[Segment<'_>] = if envelope_active {
485                    match envelope_interior(segments) {
486                        // Common case: UNB/UNZ bracket the message and no
487                        // UNG/UNE appear inside, so a sub-slice suffices and no
488                        // segment has to be deep-cloned.
489                        Some(interior) => interior,
490                        None => filtered.get_or_insert_with(|| {
491                            segments
492                                .iter()
493                                .filter(|s| !matches!(s.tag(), "UNB" | "UNZ" | "UNG" | "UNE"))
494                                .cloned()
495                                .collect()
496                        }),
497                    }
498                } else {
499                    segments
500                };
501                lv.validator
502                    .validate_batch(active, &mut report, effective_ctx);
503            }
504            if self.bail_on_first_critical && report.has_critical_errors() {
505                break;
506            }
507        }
508
509        if let Some(ref msg_ref) = self.message_ref {
510            for issue in report
511                .errors
512                .iter_mut()
513                .chain(report.warnings.iter_mut())
514                .chain(report.infos.iter_mut())
515            {
516                if issue.message_ref.is_none() {
517                    issue.message_ref = Some(msg_ref.clone());
518                }
519            }
520        }
521        // Append static advisory issues unconditionally.
522        for issue in &self.static_issues {
523            match issue.severity {
524                ValidationSeverity::Critical | ValidationSeverity::Error => {
525                    report.add_error(issue.clone());
526                }
527                ValidationSeverity::Warning => {
528                    report.warnings.push(issue.clone());
529                }
530                ValidationSeverity::Info => {
531                    report.infos.push(issue.clone());
532                }
533            }
534        }
535        report
536    }
537
538    /// Message type metadata associated with this context, if provided.
539    pub fn message_type(&self) -> Option<&str> {
540        self.message_type.as_deref()
541    }
542
543    /// Message reference (`UNH` element 0) associated with this context, if provided.
544    pub fn message_ref(&self) -> Option<&str> {
545        self.message_ref.as_deref()
546    }
547
548    /// Create a child context that inherits all rules and configuration from `self`
549    /// but is scoped to a specific message reference (UNH DE 0062).
550    ///
551    /// Issues produced by the child context are automatically stamped with
552    /// `message_ref`, making it easy to correlate findings in a multi-message
553    /// interchange back to the originating `UNH`/`UNT` envelope.
554    ///
555    /// # Example
556    ///
557    /// ```rust,ignore
558    /// let base_ctx = ValidationContext::builder()
559    ///     .with_profile_pack(mig_pack)
560    ///     .build();
561    ///
562    /// for (ref_no, message_segments) in messages {
563    ///     let child = base_ctx.fork_with_message_ref(&ref_no);
564    ///     let report = child.validate(&message_segments);
565    /// }
566    /// ```
567    pub fn fork_with_message_ref(&self, message_ref: impl Into<String>) -> Self {
568        let validators: Vec<LayeredValidator> = self
569            .validators
570            .iter()
571            .filter_map(|lv| {
572                lv.validator.fork().map(|forked| LayeredValidator {
573                    layer: lv.layer,
574                    validator: forked,
575                })
576            })
577            .collect();
578        // Count how many validators were excluded (non-forkable).
579        let excluded_count = self.validators.len() - validators.len();
580
581        let mut static_issues = self.static_issues.clone();
582        if excluded_count > 0 {
583            static_issues.push(
584                crate::ValidationIssue::new(
585                    crate::ValidationSeverity::Info,
586                    format!(
587                        "{excluded_count} validator(s) excluded from forked context \
588                         because fork() returned None; all their rules (flat and \
589                         group-pass) will not run for this message",
590                    ),
591                )
592                .with_rule_id("edifact-rs::fork::excluded-validator"),
593            );
594        }
595
596        Self {
597            validators,
598            envelope_enabled: self.envelope_enabled,
599            structure_enabled: self.structure_enabled,
600            code_list_enabled: self.code_list_enabled,
601            profile_enabled: self.profile_enabled,
602            bail_on_first_critical: self.bail_on_first_critical,
603            message_type: self.message_type.clone(),
604            message_ref: Some(message_ref.into()),
605            metadata: self.metadata.clone(),
606            static_issues,
607        }
608    }
609
610    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
611        match layer {
612            ValidationLayer::Envelope => self.envelope_enabled,
613            ValidationLayer::Structure => self.structure_enabled,
614            ValidationLayer::CodeList => self.code_list_enabled,
615            ValidationLayer::Profile => self.profile_enabled,
616        }
617    }
618
619    /// Whether an enabled envelope-layer validator is registered.
620    ///
621    /// Determines whether envelope segments are hidden from later layers.  It is
622    /// a property of the context as a whole, not of how far the validator loop
623    /// has progressed.
624    fn envelope_layer_active(&self) -> bool {
625        self.envelope_enabled
626            && self
627                .validators
628                .iter()
629                .any(|lv| lv.layer == ValidationLayer::Envelope)
630    }
631}
632
633/// Return the message body as a sub-slice when the envelope segments form a
634/// clean `UNB` … `UNZ` bracket with no functional groups inside.
635///
636/// Returns `None` when the caller must fall back to filter-and-clone (functional
637/// groups present, or the interchange is not bracketed as expected).
638fn envelope_interior<'s, 'a>(segments: &'s [Segment<'a>]) -> Option<&'s [Segment<'a>]> {
639    let (first, last) = (segments.first()?, segments.last()?);
640    if segments.len() < 2 || first.tag != "UNB" || last.tag != "UNZ" {
641        return None;
642    }
643    let interior = &segments[1..segments.len() - 1];
644    if interior
645        .iter()
646        .any(|s| matches!(s.tag(), "UNB" | "UNZ" | "UNG" | "UNE"))
647    {
648        return None;
649    }
650    Some(interior)
651}