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}