Skip to main content

edifact_rs/
validator.rs

1//! Validation pipeline for structural and semantic EDIFACT checks.
2
3use crate::{
4    EdifactError, OwnedSegment, Segment, ValidationIssue, ValidationReport, ValidationSeverity,
5};
6use std::any::Any;
7use std::sync::Arc;
8
9/// Typed context injected into profile rule closures at validation time.
10///
11/// Rules access per-call metadata via [`ValidationRuleContext::metadata`].
12/// If no metadata was injected, every `metadata()` call returns `None`.
13///
14/// # Example
15///
16/// ```rust,ignore
17/// let pack = ProfileRulePack::new("AHB-11001")
18///     .with_rule_fn(|segs, ctx| {
19///         let pruefid: &Pruefid = ctx.metadata()?;
20///         // use pruefid …
21///         None
22///     });
23///
24/// let report = ValidationContext::builder()
25///     .with_profile_pack(pack)
26///     .build()
27///     .validate_lenient_with(&segments, &my_pruefid);
28/// ```
29#[derive(Clone, Copy)]
30pub struct ValidationRuleContext<'a> {
31    metadata: Option<&'a (dyn Any + Send + Sync)>,
32}
33
34impl<'a> ValidationRuleContext<'a> {
35    /// Construct a context with no metadata.
36    pub fn empty() -> Self {
37        Self { metadata: None }
38    }
39
40    /// Construct a context holding a typed metadata reference.
41    pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
42        Self {
43            metadata: Some(value as &(dyn Any + Send + Sync)),
44        }
45    }
46
47    /// Downcast the metadata to `T`.  Returns `None` if no metadata was
48    /// injected or if the concrete type does not match `T`.
49    pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
50        self.metadata?.downcast_ref::<T>()
51    }
52
53    /// Return `true` if metadata was provided.
54    pub fn has_metadata(&self) -> bool {
55        self.metadata.is_some()
56    }
57}
58
59impl std::fmt::Debug for ValidationRuleContext<'_> {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("ValidationRuleContext")
62            .field("has_metadata", &self.metadata.is_some())
63            .finish()
64    }
65}
66
67/// A profile rule that can be added to a [`ProfileRulePack`].
68///
69/// Implement this trait to create reusable, composable profile rules for
70/// EDIFACT message validation.  Rules receive a [`ValidationRuleContext`] that
71/// provides optional typed metadata injected at validation call time via
72/// [`ValidationContext::validate_lenient_with`].
73pub trait ProfileRule: Send + Sync {
74    /// Evaluate the rule against the given segments.
75    ///
76    /// Return `Some(issue)` if the rule is violated, or `None` if the segments pass.
77    fn evaluate(
78        &self,
79        segments: &[Segment<'_>],
80        context: &ValidationRuleContext<'_>,
81    ) -> Option<ValidationIssue>;
82}
83
84/// Wraps a context-aware closure as a [`ProfileRule`].
85struct ClosureProfileRule<F>(F);
86
87impl<F> ProfileRule for ClosureProfileRule<F>
88where
89    F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>) -> Option<ValidationIssue>
90        + Send
91        + Sync,
92{
93    fn evaluate(
94        &self,
95        segments: &[Segment<'_>],
96        context: &ValidationRuleContext<'_>,
97    ) -> Option<ValidationIssue> {
98        (self.0)(segments, context)
99    }
100}
101
102/// Wraps a context-free closure as a [`ProfileRule`] (ignores the context parameter).
103struct StatelessClosureProfileRule<F>(F);
104
105impl<F> ProfileRule for StatelessClosureProfileRule<F>
106where
107    F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync,
108{
109    fn evaluate(
110        &self,
111        segments: &[Segment<'_>],
112        _context: &ValidationRuleContext<'_>,
113    ) -> Option<ValidationIssue> {
114        (self.0)(segments)
115    }
116}
117
118/// A rule entry inside a [`ProfileRulePack`], optionally carrying a stable identifier.
119///
120/// The `id` is used by [`ProfileRulePack::merge_with_override`] to de-duplicate rules:
121/// when two packs contain a rule with the same id, the rule from the *other* (override)
122/// pack replaces the one in `self`.
123struct NamedRule {
124    /// Stable identifier for this rule, e.g. `"AHB-11001-BGM-M"`.
125    ///
126    /// `None` for anonymous rules that can never be overridden by id.
127    id: Option<Arc<str>>,
128    rule: Arc<dyn ProfileRule + Send + Sync>,
129}
130
131impl Clone for NamedRule {
132    fn clone(&self) -> Self {
133        Self {
134            id: self.id.clone(),
135            rule: Arc::clone(&self.rule),
136        }
137    }
138}
139
140/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
141pub struct ProfileRulePack {
142    name: String,
143    /// Set of EDIFACT message types this pack is scoped to (e.g. `"ORDERS"`, `"INVOIC"`).
144    ///
145    /// `BTreeSet` provides O(log n) membership tests and deterministic iteration order
146    /// without requiring the `hashbrown` dependency.  Profile packs rarely contain more
147    /// than a handful of types, so the difference over a `Vec` is negligible in practice,
148    /// but the semantics (no duplicates, sorted iteration) are more correct.
149    message_types: std::collections::BTreeSet<String>,
150    /// Association-assigned code (DE 0057) this pack is bound to, e.g. `"5.5.3a"`.
151    ///
152    /// `None` means the pack applies universally regardless of association code.
153    release: Option<String>,
154    rules: Vec<NamedRule>,
155    bail_on_first_error: bool,
156}
157
158impl ProfileRulePack {
159    /// Create an empty rule pack.
160    pub fn new(name: impl Into<String>) -> Self {
161        Self {
162            name: name.into(),
163            message_types: std::collections::BTreeSet::new(),
164            release: None,
165            rules: Vec::new(),
166            bail_on_first_error: false,
167        }
168    }
169
170    /// Return the pack name.
171    pub fn name(&self) -> &str {
172        &self.name
173    }
174
175    /// Return the message types this pack is scoped to.
176    pub fn message_types(&self) -> impl Iterator<Item = &str> {
177        self.message_types.iter().map(|s| s.as_str())
178    }
179
180    /// Return the number of rules in this pack.
181    pub fn rule_count(&self) -> usize {
182        self.rules.len()
183    }
184
185    /// Iterate over the stable identifiers of all **named** rules in this pack.
186    ///
187    /// Anonymous rules (added without an id) are skipped.
188    pub fn rule_ids(&self) -> impl Iterator<Item = &str> {
189        self.rules.iter().filter_map(|r| r.id.as_deref())
190    }
191
192    /// Return the association-assigned release code this pack is bound to, if any.
193    ///
194    /// `None` means the pack applies to messages of any association code.
195    pub fn release(&self) -> Option<&str> {
196        self.release.as_deref()
197    }
198
199    /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
200    ///
201    /// When a pack has one or more message-type restrictions, its rules are only evaluated
202    /// against messages whose `UNH` element 1, component 0 matches one of the registered
203    /// types (e.g. `"ORDERS"`, `"INVOIC"`).
204    ///
205    /// # Silent-skip behaviour
206    ///
207    /// If the input segments do not contain a `UNH` segment, or if the `UNH` message-type
208    /// element is absent, the pack will **silently skip all rules** rather than returning an
209    /// error.  This is intentional: without a readable message type the pack cannot
210    /// determine whether its rules apply, so it errs on the side of no false positives.
211    ///
212    /// If you need a hard failure on a missing `UNH`, add a dedicated [`ProfileRule`] that
213    /// checks for the segment's presence before other rules run.
214    pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
215        self.message_types.insert(message_type.into());
216        self
217    }
218
219    /// Bind this pack to a specific association-assigned code (DE 0057).
220    ///
221    /// When a release is set, rules are only evaluated against messages whose
222    /// `UNH` element 1, component 4 matches `release` exactly (e.g. `"5.5.3a"`).
223    /// Packs with no bound release are universal — they run for every message
224    /// regardless of its association code.
225    ///
226    /// # Example
227    ///
228    /// ```rust,ignore
229    /// let pack = ProfileRulePack::new("UTILMD-5.5.3a")
230    ///     .for_message_type("UTILMD")
231    ///     .for_release("5.5.3a");
232    /// ```
233    pub fn for_release(mut self, release: impl Into<String>) -> Self {
234        self.release = Some(release.into());
235        self
236    }
237
238    /// Stop evaluating rules in this pack after the first `Error`- or `Critical`-severity
239    /// finding.
240    ///
241    /// Bail applies *per pack*, not globally — other packs in the
242    /// [`ValidationContext`] still run even when this pack bails early.  This
243    /// avoids flooding validation reports with cascading false positives when a
244    /// mandatory segment is missing and all subsequent rules reference its content.
245    pub fn bail_on_first_error(mut self, bail: bool) -> Self {
246        self.bail_on_first_error = bail;
247        self
248    }
249
250    /// Add a context-aware rule closure.
251    ///
252    /// The closure receives both the segment slice and a [`ValidationRuleContext`]
253    /// that may carry typed metadata injected at validation call time via
254    /// [`ValidationContext::validate_lenient_with`].
255    ///
256    /// For rules that do not need context, use [`with_stateless_rule_fn`][Self::with_stateless_rule_fn].
257    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
258    where
259        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>) -> Option<ValidationIssue>
260            + Send
261            + Sync
262            + 'static,
263    {
264        self.rules.push(NamedRule {
265            id: None,
266            rule: Arc::new(ClosureProfileRule(rule)),
267        });
268        self
269    }
270
271    /// Add a context-aware rule closure with a stable identifier.
272    ///
273    /// The `id` is used by [`merge_with_override`][Self::merge_with_override] to de-duplicate
274    /// rules across packs: if `other` has a rule with the same `id`, it replaces the
275    /// corresponding rule in `self`.
276    pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
277    where
278        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>) -> Option<ValidationIssue>
279            + Send
280            + Sync
281            + 'static,
282    {
283        self.rules.push(NamedRule {
284            id: Some(id.into()),
285            rule: Arc::new(ClosureProfileRule(rule)),
286        });
287        self
288    }
289
290    /// Add a context-free rule closure.
291    ///
292    /// Convenience wrapper for rules that do not inspect the
293    /// [`ValidationRuleContext`].
294    pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
295    where
296        F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync + 'static,
297    {
298        self.rules.push(NamedRule {
299            id: None,
300            rule: Arc::new(StatelessClosureProfileRule(rule)),
301        });
302        self
303    }
304
305    /// Add a context-free rule closure with a stable identifier.
306    ///
307    /// See [`with_named_rule_fn`][Self::with_named_rule_fn] for override semantics.
308    pub fn with_named_stateless_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
309    where
310        F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync + 'static,
311    {
312        self.rules.push(NamedRule {
313            id: Some(id.into()),
314            rule: Arc::new(StatelessClosureProfileRule(rule)),
315        });
316        self
317    }
318
319    /// Add a rule that implements [`ProfileRule`].
320    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
321        self.rules.push(NamedRule {
322            id: None,
323            rule: Arc::new(rule),
324        });
325        self
326    }
327
328    /// Add a named rule that implements [`ProfileRule`].
329    ///
330    /// See [`with_named_rule_fn`][Self::with_named_rule_fn] for override semantics.
331    pub fn with_named_rule(
332        mut self,
333        id: impl Into<Arc<str>>,
334        rule: impl ProfileRule + 'static,
335    ) -> Self {
336        self.rules.push(NamedRule {
337            id: Some(id.into()),
338            rule: Arc::new(rule),
339        });
340        self
341    }
342
343    /// Prepend all rules from `base` to this pack.
344    ///
345    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
346    /// Message-type restrictions from `base` are also merged.  The resulting
347    /// release scope must be compatible with both packs: if one pack is scoped
348    /// to a release and the other is not, the scope is preserved; if both are
349    /// scoped, they must match.
350    ///
351    /// # Example
352    ///
353    /// ```rust,ignore
354    /// let base = ProfileRulePack::new("MIG-UTILMD-BASE")
355    ///     .with_stateless_rule_fn(/* mandatory segment rules */);
356    ///
357    /// let ahb_11001 = ProfileRulePack::new("AHB-11001")
358    ///     .extend_from(&base)
359    ///     .with_stateless_rule_fn(/* 11001-specific rules */);
360    /// ```
361    pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
362        let mut combined = base.rules.clone();
363        combined.append(&mut self.rules);
364        self.rules = combined;
365        for mt in &base.message_types {
366            self.message_types.insert(mt.clone());
367        }
368        self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
369        Ok(self)
370    }
371
372    /// Merge two packs into one combined pack.
373    ///
374    /// Rules from `self` run before rules from `other`.  If both packs contain
375    /// named rules with the same id, **both run** — use
376    /// [`merge_with_override`][Self::merge_with_override] to de-duplicate by id instead.
377    /// Release scoping follows the same compatibility rule as
378    /// [`extend_from`][Self::extend_from].
379    pub fn merge(mut self, mut other: Self) -> Result<Self, EdifactError> {
380        self.message_types.append(&mut other.message_types);
381        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
382        self.rules.append(&mut other.rules);
383        Ok(self)
384    }
385
386    /// Merge `other` into `self`, with `other` taking precedence for any rule
387    /// whose id already exists in `self`.
388    ///
389    /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
390    ///   the rule at the same position in `self`.
391    /// - Rules in `other` with no id, or with an id not present in `self`, are
392    ///   **appended** to `self`.
393    /// - Rules present only in `self` (no matching override in `other`) are
394    ///   **retained unchanged**.
395    ///
396    /// Message-type restrictions from `other` are merged into `self`.
397    /// Release scoping follows the same compatibility rule as
398    /// [`extend_from`][Self::extend_from].
399    ///
400    /// # Example
401    ///
402    /// ```rust,ignore
403    /// let base = ProfileRulePack::new("UTILMD-5.4")
404    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs| { /* old rule */ None });
405    ///
406    /// let delta = ProfileRulePack::new("UTILMD-5.5-delta")
407    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs| { /* updated rule */ None });
408    ///
409    /// // `result` runs the updated BGM-M rule only once:
410    /// let result = base.merge_with_override(delta);
411    /// assert_eq!(result.rule_count(), 1);
412    /// ```
413    pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
414        // Build an id→index map for self.rules to avoid O(n*m) behavior.
415        let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
416        for (idx, rule) in self.rules.iter().enumerate() {
417            if let Some(id) = &rule.id {
418                id_to_index.insert(id.clone(), idx);
419            }
420        }
421
422        // Process overrides in a single pass: collect replacements and appends.
423        let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
424        let mut to_append = Vec::new();
425
426        for other_rule in other.rules.drain(..) {
427            if let Some(id) = &other_rule.id {
428                if let Some(&idx) = id_to_index.get(id) {
429                    replacements.push((idx, other_rule));
430                } else {
431                    to_append.push(other_rule);
432                }
433            } else {
434                to_append.push(other_rule);
435            }
436        }
437
438        // Apply replacements in-place.
439        for (idx, rule) in replacements {
440            if idx < self.rules.len() {
441                self.rules[idx] = rule;
442            }
443        }
444
445        // Append new rules.
446        self.rules.append(&mut to_append);
447
448        self.message_types.append(&mut other.message_types);
449        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
450        Ok(self)
451    }
452}
453
454fn merge_release_scopes(
455    current: Option<String>,
456    incoming: Option<String>,
457) -> Result<Option<String>, EdifactError> {
458    match (current, incoming) {
459        (Some(current), Some(incoming)) => {
460            // Both packs specify a release; they must match to compose safely.
461            if current != incoming {
462                return Err(EdifactError::IncompatibleReleaseScopes { current, incoming });
463            }
464            Ok(Some(current))
465        }
466        (current @ Some(_), None) => Ok(current),
467        (None, incoming) => Ok(incoming),
468    }
469}
470
471impl Validator for ProfileRulePack {
472    fn validate_batch(
473        &self,
474        segments: &[Segment<'_>],
475        report: &mut ValidationReport,
476        context: &ValidationRuleContext<'_>,
477    ) {
478        let unh = segments.iter().find(|segment| segment.tag == "UNH");
479
480        // Message-type filter: skip if no registered type matches.
481        let message_type = unh
482            .and_then(|s| s.get_element(1))
483            .and_then(|e| e.get_component(0));
484        if !self.message_types.is_empty()
485            && !message_type.is_some_and(|mt| self.message_types.contains(mt))
486        {
487            return;
488        }
489
490        // Release filter: skip if pack is bound to a specific association code that
491        // does not match the message's UNH DE 0057 (element 1, component 4).
492        if let Some(bound_release) = &self.release {
493            let msg_association = unh
494                .and_then(|s| s.get_element(1))
495                .and_then(|e| e.get_component(4));
496            if msg_association != Some(bound_release.as_str()) {
497                return;
498            }
499        }
500
501        for named in &self.rules {
502            if let Some(issue) = named.rule.evaluate(segments, context) {
503                let was_error = match issue.severity {
504                    ValidationSeverity::Critical | ValidationSeverity::Error => {
505                        report.add_error(issue);
506                        true
507                    }
508                    ValidationSeverity::Warning => {
509                        report.add_warning(issue);
510                        false
511                    }
512                    ValidationSeverity::Info => {
513                        report.add_info(issue);
514                        false
515                    }
516                };
517                if self.bail_on_first_error && was_error {
518                    return;
519                }
520            }
521        }
522    }
523}
524
525impl std::fmt::Debug for ProfileRulePack {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        f.debug_struct("ProfileRulePack")
528            .field("name", &self.name)
529            .field("message_types", &self.message_types)
530            .field("release", &self.release)
531            .field("rule_count", &self.rules.len())
532            .field("bail_on_first_error", &self.bail_on_first_error)
533            .finish()
534    }
535}
536
537/// Validation layers used by [`ValidationContext`].
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539#[non_exhaustive]
540pub enum ValidationLayer {
541    /// Interchange / message envelope checks (`UNB`/`UNH`/`UNT`/`UNZ` counts).
542    Envelope,
543    /// Directory structure checks (segment presence/order/arity).
544    Structure,
545    /// Directory code-list checks.
546    CodeList,
547    /// Downstream profile-pack checks.
548    Profile,
549}
550
551struct LayeredValidator {
552    layer: ValidationLayer,
553    validator: Box<dyn Validator + Send + Sync>,
554}
555
556/// Runtime validation context for progressive layered validation.
557pub struct ValidationContext {
558    validators: Vec<LayeredValidator>,
559    envelope_enabled: bool,
560    structure_enabled: bool,
561    code_list_enabled: bool,
562    profile_enabled: bool,
563    message_type: Option<String>,
564    /// Injected into every emitted `ValidationIssue` when set.
565    message_ref: Option<String>,
566    metadata: Option<Arc<dyn Any + Send + Sync>>,
567}
568
569/// Builder for [`ValidationContext`].
570#[must_use = "call `.build()` to produce a `ValidationContext`"]
571pub struct ValidationContextBuilder {
572    inner: ValidationContext,
573}
574
575impl Default for ValidationContextBuilder {
576    /// Default context builder has all layers enabled, same as [`ValidationContextBuilder::new`].
577    fn default() -> Self {
578        Self::new()
579    }
580}
581
582impl ValidationContextBuilder {
583    /// Create a new context builder with all layers enabled.
584    pub fn new() -> Self {
585        Self {
586            inner: ValidationContext {
587                validators: Vec::new(),
588                envelope_enabled: false,
589                structure_enabled: true,
590                code_list_enabled: true,
591                profile_enabled: true,
592                message_type: None,
593                message_ref: None,
594                metadata: None,
595            },
596        }
597    }
598
599    /// Attach typed metadata accessible to context-aware profile rules.
600    ///
601    /// Rules added with [`ProfileRulePack::with_rule_fn`] receive the metadata
602    /// via [`ValidationRuleContext::metadata`] on every call to
603    /// [`ValidationContext::validate_lenient`].
604    ///
605    /// For per-call metadata that varies between validation invocations, use
606    /// [`ValidationContext::validate_lenient_with`] instead.
607    pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
608        self.inner.metadata = Some(Arc::new(value));
609        self
610    }
611
612    /// Stamp every issue produced by this context with the given message reference.
613    ///
614    /// The message reference corresponds to DE 0062 from the `UNH` segment.
615    /// Use this when validating individual messages from a multi-message
616    /// interchange so that issues in the resulting [`ValidationReport`] can be
617    /// correlated back to the originating `UNH`/`UNT` envelope.
618    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
619        self.inner.message_ref = Some(message_ref.into());
620        self
621    }
622
623    /// Set message type metadata for downstream validators.
624    pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
625        self.inner.message_type = Some(message_type.into());
626        let configured = self.inner.message_type.as_deref();
627        for layered in &mut self.inner.validators {
628            layered.validator.set_message_type(configured);
629        }
630        self
631    }
632
633    /// Enable/disable structure validators.
634    pub fn structure(mut self, enabled: bool) -> Self {
635        self.inner.structure_enabled = enabled;
636        self
637    }
638
639    /// Enable/disable code-list validators.
640    pub fn code_list(mut self, enabled: bool) -> Self {
641        self.inner.code_list_enabled = enabled;
642        self
643    }
644
645    /// Enable/disable profile validators.
646    pub fn profile(mut self, enabled: bool) -> Self {
647        self.inner.profile_enabled = enabled;
648        self
649    }
650
651    /// Enable/disable envelope layer validators.
652    ///
653    /// Off by default.  Call [`with_envelope_validation`][Self::with_envelope_validation]
654    /// to add the built-in [`EnvelopeValidator`] and enable the layer in one step.
655    pub fn envelope(mut self, enabled: bool) -> Self {
656        self.inner.envelope_enabled = enabled;
657        self
658    }
659
660    /// Add the built-in [`EnvelopeValidator`] and enable the envelope layer.
661    ///
662    /// The built-in validator mirrors [`crate::validate_envelope`] but
663    /// translates each structural error into a [`ValidationIssue`] so all
664    /// issues land in the unified [`ValidationReport`] alongside profile and
665    /// directory findings.
666    ///
667    /// # Example
668    ///
669    /// ```rust,ignore
670    /// let report = ValidationContext::builder()
671    ///     .with_envelope_validation()
672    ///     .with_message_type("ORDERS")
673    ///     .build()
674    ///     .validate_lenient(&all_segments);
675    /// ```
676    pub fn with_envelope_validation(mut self) -> Self {
677        self.inner.envelope_enabled = true;
678        self.inner.validators.push(LayeredValidator {
679            layer: ValidationLayer::Envelope,
680            validator: Box::new(EnvelopeValidator),
681        });
682        self
683    }
684
685    /// Add a validator assigned to `layer`.
686    pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
687    where
688        V: Validator + 'static,
689    {
690        validator.set_message_type(self.inner.message_type.as_deref());
691        self.inner.validators.push(LayeredValidator {
692            layer,
693            validator: Box::new(validator),
694        });
695        self
696    }
697
698    /// Add a profile rule pack to the profile layer.
699    pub fn with_profile_pack(mut self, mut pack: ProfileRulePack) -> Self {
700        pack.set_message_type(self.inner.message_type.as_deref());
701        self.inner.validators.push(LayeredValidator {
702            layer: ValidationLayer::Profile,
703            validator: Box::new(pack),
704        });
705        self
706    }
707
708    /// Finalize builder and create context.
709    #[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
710    pub fn build(self) -> ValidationContext {
711        self.inner
712    }
713}
714
715impl ValidationContext {
716    /// Start building a validation context.
717    pub fn builder() -> ValidationContextBuilder {
718        ValidationContextBuilder::new()
719    }
720
721    /// Execute validators in lenient mode for enabled layers.
722    ///
723    /// Uses any metadata set via [`ValidationContextBuilder::with_metadata`].
724    /// For per-call metadata, use [`validate_lenient_with`][Self::validate_lenient_with].
725    pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
726        let ctx = self
727            .metadata
728            .as_ref()
729            .map(|arc| ValidationRuleContext {
730                metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
731            })
732            .unwrap_or_else(ValidationRuleContext::empty);
733        self.validate_with_context(segments, &ctx)
734    }
735
736    /// Execute validators with per-call typed metadata.
737    ///
738    /// The metadata is accessible inside context-aware rule closures via
739    /// [`ValidationRuleContext::metadata`].  This is the recommended path when
740    /// a single [`ProfileRulePack`] serves multiple process-variant contexts
741    /// (e.g., one pack per message type, injecting the Pruefidentifikator at
742    /// call time).
743    pub fn validate_lenient_with<T: Any + Send + Sync>(
744        &self,
745        segments: &[Segment<'_>],
746        value: &T,
747    ) -> ValidationReport {
748        let ctx = ValidationRuleContext::new(value);
749        self.validate_with_context(segments, &ctx)
750    }
751
752    /// Execute validators in strict mode for enabled layers.
753    ///
754    /// Returns `Ok(report)` when validation produces no errors.  The `Err` variant
755    /// **also contains the full report** (errors, warnings, and infos) so that
756    /// callers can inspect all issues even on failure.
757    ///
758    /// Warnings do **not** cause this method to return `Err`.  Call
759    /// [`validate_lenient`][Self::validate_lenient] if you want to inspect warnings
760    /// without failing on errors.
761    pub fn validate_strict(
762        &self,
763        segments: &[Segment<'_>],
764    ) -> Result<ValidationReport, ValidationReport> {
765        self.validate_lenient(segments).result()
766    }
767
768    /// Execute validators in strict mode with per-call typed metadata.
769    ///
770    /// See [`validate_lenient_with`][Self::validate_lenient_with] for context usage and
771    /// [`validate_strict`][Self::validate_strict] for strict-mode semantics.
772    pub fn validate_strict_with<T: Any + Send + Sync>(
773        &self,
774        segments: &[Segment<'_>],
775        value: &T,
776    ) -> Result<ValidationReport, ValidationReport> {
777        self.validate_lenient_with(segments, value).result()
778    }
779
780    /// Execute validators in lenient mode against an owned-segment slice.
781    ///
782    /// Converts owned segments to borrowed segments on-the-fly and delegates to
783    /// [`validate_lenient`][Self::validate_lenient].
784    pub fn validate_lenient_owned(&self, segments: &[OwnedSegment]) -> ValidationReport {
785        let borrowed: Vec<Segment<'_>> = segments.iter().map(|s| s.as_borrowed()).collect();
786        self.validate_lenient(&borrowed)
787    }
788
789    /// Execute validators in strict mode against an owned-segment slice.
790    ///
791    /// Equivalent to [`validate_strict`][Self::validate_strict] but accepts
792    /// `&[OwnedSegment]` directly, avoiding a manual `.as_borrowed()` conversion
793    /// at the call site.
794    pub fn validate_strict_owned(
795        &self,
796        segments: &[OwnedSegment],
797    ) -> Result<ValidationReport, ValidationReport> {
798        self.validate_lenient_owned(segments).result()
799    }
800
801    fn validate_with_context(
802        &self,
803        segments: &[Segment<'_>],
804        context: &ValidationRuleContext<'_>,
805    ) -> ValidationReport {
806        let mut report = ValidationReport::default();
807        for lv in &self.validators {
808            if self.layer_enabled(lv.layer) {
809                lv.validator.validate_batch(segments, &mut report, context);
810            }
811        }
812        // Stamp every issue with the message reference if one was configured.
813        if let Some(ref msg_ref) = self.message_ref {
814            for issue in report
815                .errors
816                .iter_mut()
817                .chain(report.warnings.iter_mut())
818                .chain(report.infos.iter_mut())
819            {
820                if issue.message_ref.is_none() {
821                    issue.message_ref = Some(msg_ref.clone());
822                }
823            }
824        }
825        report
826    }
827
828    /// Message type metadata associated with this context, if provided.
829    pub fn message_type(&self) -> Option<&str> {
830        self.message_type.as_deref()
831    }
832
833    /// Message reference (`UNH` element 0) associated with this context, if provided.
834    pub fn message_ref(&self) -> Option<&str> {
835        self.message_ref.as_deref()
836    }
837
838    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
839        match layer {
840            ValidationLayer::Envelope => self.envelope_enabled,
841            ValidationLayer::Structure => self.structure_enabled,
842            ValidationLayer::CodeList => self.code_list_enabled,
843            ValidationLayer::Profile => self.profile_enabled,
844        }
845    }
846}
847
848/// Pluggable validator for parsed EDIFACT segments.
849///
850/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
851/// entire segment sequence and appends issues to a [`ValidationReport`].
852///
853/// Validators receive a [`ValidationRuleContext`] that may carry typed metadata
854/// injected at validation call time.  Implementations that do not need the
855/// context may ignore it.
856///
857/// For validators that work segment-by-segment, the convenience function
858/// [`validate_each`] iterates over the slice and calls a per-segment closure,
859/// so you only need to implement `validate_batch`:
860///
861/// ```rust,ignore
862/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, _ctx: &ValidationRuleContext<'_>) {
863///     validate_each(segments, report, |seg| {
864///         // return Ok(()) or Err(EdifactError::...)
865///         Ok(())
866///     });
867/// }
868/// ```
869pub trait Validator: Send + Sync {
870    /// Validate a full segment set and append issues to `report`.
871    ///
872    /// Implementations that do not need the context may ignore the `context` parameter.
873    fn validate_batch(
874        &self,
875        segments: &[Segment<'_>],
876        report: &mut ValidationReport,
877        context: &ValidationRuleContext<'_>,
878    );
879
880    /// Configure message-type metadata for validators that support explicit scoping.
881    fn set_message_type(&mut self, _message_type: Option<&str>) {}
882}
883
884/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
885/// and converts any `Err` into report entries.
886///
887/// Use this in `validate_batch` implementations that work segment-by-segment:
888///
889/// ```rust,ignore
890/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
891///     validate_each(segments, report, |seg| { /* ... */ Ok(()) });
892/// }
893/// ```
894pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
895where
896    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
897{
898    for segment in segments {
899        if let Err(err) = f(segment) {
900            report_error(report, err);
901        }
902    }
903}
904
905/// Convert a low-level validation error to a user-facing issue and append it.
906pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
907    let issue = issue_from_error(err);
908    match issue.severity {
909        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
910        ValidationSeverity::Warning => report.add_warning(issue),
911        ValidationSeverity::Info => report.add_info(issue),
912    }
913}
914
915// ── EnvelopeValidator ─────────────────────────────────────────────────────────
916
917/// Built-in validator for EDIFACT interchange envelope structure.
918///
919/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
920/// segment counts.  Registered by
921/// [`ValidationContextBuilder::with_envelope_validation`].
922///
923/// The validator translates each [`EdifactError`] from
924/// [`crate::validate_envelope`] into a [`ValidationIssue`] so that envelope
925/// findings appear in the unified [`ValidationReport`] alongside structure and
926/// profile results.
927pub struct EnvelopeValidator;
928
929impl Validator for EnvelopeValidator {
930    fn validate_batch(
931        &self,
932        segments: &[Segment<'_>],
933        report: &mut ValidationReport,
934        _ctx: &ValidationRuleContext<'_>,
935    ) {
936        if let Err(e) = crate::envelope::validate_envelope(segments) {
937            report_error(report, e);
938        }
939    }
940}
941
942fn issue_from_error(err: EdifactError) -> ValidationIssue {
943    let code = err.stable_code();
944    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
945    let default_hint = err.recovery_hint();
946
947    match err {
948        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
949            issue = issue.with_segment(tag).with_offset(offset);
950        }
951        EdifactError::InvalidElementCount { tag, offset, .. } => {
952            issue = issue.with_segment(tag).with_offset(offset);
953        }
954        EdifactError::InvalidComponentCount {
955            tag,
956            element_index,
957            offset,
958            ..
959        } => {
960            issue = issue
961                .with_segment(tag)
962                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
963                .with_offset(offset);
964        }
965        EdifactError::InvalidCodeValue {
966            tag,
967            element_index,
968            offset,
969            suggestion,
970            ..
971        } => {
972            issue = issue
973                .with_segment(tag)
974                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
975                .with_offset(offset);
976            if let Some(s) = suggestion {
977                issue = issue.with_suggestion(s);
978            }
979        }
980        EdifactError::MissingSegment { tag, .. } => {
981            issue = issue.with_segment(tag);
982        }
983        EdifactError::QualifierMismatch { tag, offset, .. } => {
984            issue = issue
985                .with_segment(tag)
986                .with_element_index(0)
987                .with_offset(offset);
988        }
989        EdifactError::ConditionalRequirementNotMet {
990            tag,
991            element_index,
992            offset,
993            ..
994        } => {
995            issue = issue
996                .with_segment(tag)
997                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
998                .with_offset(offset);
999        }
1000        EdifactError::MissingRequiredElement { tag, element_index } => {
1001            issue = issue.with_segment(tag);
1002            if let Ok(idx) = u8::try_from(element_index) {
1003                issue = issue.with_element_index(idx);
1004            }
1005        }
1006        EdifactError::MissingRequiredComponent {
1007            tag,
1008            element_index,
1009            component_index,
1010        } => {
1011            issue = issue.with_segment(tag);
1012            if let Ok(ei) = u8::try_from(element_index) {
1013                issue = issue.with_element_index(ei);
1014            }
1015            if let Ok(ci) = u8::try_from(component_index) {
1016                issue = issue.with_component_index(ci);
1017            }
1018        }
1019        EdifactError::InvalidReleaseSequence { offset }
1020        | EdifactError::InvalidDelimiter { offset, .. }
1021        | EdifactError::InvalidText { offset }
1022        | EdifactError::UnexpectedEof { offset }
1023        | EdifactError::UnexpectedDataToken { offset }
1024        | EdifactError::FunctionalGroupNotSupported { offset } => {
1025            issue = issue.with_offset(offset);
1026        }
1027        _ => {}
1028    }
1029
1030    if issue.suggestion.is_none() {
1031        if let Some(hint) = default_hint {
1032            issue = issue.with_suggestion(hint);
1033        }
1034    }
1035
1036    issue
1037}
1038
1039fn severity_for(err: &EdifactError) -> ValidationSeverity {
1040    match err {
1041        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
1042            ValidationSeverity::Warning
1043        }
1044        _ => ValidationSeverity::Error,
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051    use crate::model::Element;
1052
1053    fn demo_orders_profile_pack() -> ProfileRulePack {
1054        ProfileRulePack::new("ORDERS-DEMO")
1055            .for_message_type("ORDERS")
1056            .with_stateless_rule_fn(|segments| {
1057                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
1058                let document_code = bgm.get_element(0)?.get_component(0)?;
1059                (document_code == "220").then(|| {
1060                    ValidationIssue::new(
1061                        ValidationSeverity::Error,
1062                        "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
1063                    )
1064                    .with_rule_id("DEMO-P001")
1065                    .with_segment("BGM")
1066                    .with_element_index(0)
1067                    .with_suggestion("Use a different BGM document code in this demo pack")
1068                })
1069            })
1070            .with_stateless_rule_fn(|segments| {
1071                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
1072                let reference = bgm.get_element(1)?.get_component(0)?;
1073                (reference == "PO123").then(|| {
1074                    ValidationIssue::new(
1075                        ValidationSeverity::Warning,
1076                        "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
1077                    )
1078                    .with_rule_id("DEMO-P002")
1079                    .with_segment("BGM")
1080                    .with_element_index(1)
1081                    .with_suggestion("Use a non-reserved reference in this demo pack")
1082                })
1083            })
1084    }
1085
1086    struct RejectBgm;
1087
1088    struct WarnBgm;
1089
1090    impl Validator for RejectBgm {
1091        fn validate_batch(
1092            &self,
1093            segments: &[Segment<'_>],
1094            report: &mut ValidationReport,
1095            _context: &ValidationRuleContext<'_>,
1096        ) {
1097            validate_each(segments, report, |segment| {
1098                if segment.tag == "BGM" {
1099                    return Err(EdifactError::InvalidSegmentForMessage {
1100                        tag: "BGM".to_owned(),
1101                        message_type: "TEST".to_owned(),
1102                        offset: segment.tag_span.start,
1103                    });
1104                }
1105                Ok(())
1106            });
1107        }
1108    }
1109
1110    impl Validator for WarnBgm {
1111        fn validate_batch(
1112            &self,
1113            segments: &[Segment<'_>],
1114            report: &mut ValidationReport,
1115            _context: &ValidationRuleContext<'_>,
1116        ) {
1117            validate_each(segments, report, |segment| {
1118                if segment.tag == "BGM" {
1119                    return Err(EdifactError::InvalidCodeValue {
1120                        tag: "BGM".to_owned(),
1121                        element_index: 0,
1122                        value: "XXX".to_owned(),
1123                        code_list: "1001".to_owned(),
1124                        offset: segment.span.start,
1125                        suggestion: None,
1126                    });
1127                }
1128                Ok(())
1129            });
1130        }
1131    }
1132
1133    fn test_segment(tag: &'static str) -> Segment<'static> {
1134        Segment {
1135            tag,
1136            span: crate::Span::new(0, 0),
1137            tag_span: crate::Span::new(0, 0),
1138            elements: vec![Element::of(&["x"])],
1139        }
1140    }
1141
1142    #[test]
1143    fn lenient_collects_issues() {
1144        let segments = vec![test_segment("UNH"), test_segment("BGM")];
1145        let mut report = ValidationReport::default();
1146        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
1147        assert!(report.has_errors());
1148        assert_eq!(report.errors().len(), 1);
1149    }
1150
1151    #[test]
1152    fn strict_fails_on_errors() {
1153        let segments = vec![test_segment("BGM")];
1154        let mut report = ValidationReport::default();
1155        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
1156        assert!(report.has_errors());
1157        assert_eq!(report.errors().len(), 1);
1158    }
1159
1160    #[test]
1161    fn context_builder_respects_layer_toggles() {
1162        let segments = vec![test_segment("BGM")];
1163        let ctx = ValidationContext::builder()
1164            .structure(false)
1165            .with_validator(ValidationLayer::Structure, RejectBgm)
1166            .with_validator(ValidationLayer::CodeList, WarnBgm)
1167            .build();
1168
1169        let report = ctx.validate_lenient(&segments);
1170        assert!(!report.has_errors());
1171        assert_eq!(report.warnings().len(), 1);
1172    }
1173
1174    #[test]
1175    fn context_strict_fails_when_structure_enabled() {
1176        let segments = vec![test_segment("BGM")];
1177        let ctx = ValidationContext::builder()
1178            .with_message_type("ORDERS")
1179            .with_validator(ValidationLayer::Structure, RejectBgm)
1180            .build();
1181
1182        assert_eq!(ctx.message_type(), Some("ORDERS"));
1183        let result = ctx.validate_strict(&segments);
1184        assert!(result.is_err());
1185        assert!(result.unwrap_err().has_errors());
1186    }
1187
1188    #[test]
1189    fn report_error_applies_default_recovery_hint() {
1190        let mut report = ValidationReport::default();
1191        report_error(
1192            &mut report,
1193            EdifactError::InvalidReleaseSequence { offset: 9 },
1194        );
1195
1196        let issue = report
1197            .errors()
1198            .first()
1199            .expect("expected one issue in the report");
1200        let hint = issue
1201            .suggestion
1202            .as_deref()
1203            .expect("expected default hint to be set");
1204        assert!(hint.contains("Release character"));
1205        assert_eq!(issue.error_code, Some("E019"));
1206    }
1207
1208    #[test]
1209    fn missing_required_component_maps_metadata_to_issue() {
1210        let mut report = ValidationReport::default();
1211        report_error(
1212            &mut report,
1213            EdifactError::MissingRequiredComponent {
1214                tag: "BGM".to_owned(),
1215                element_index: 2,
1216                component_index: 1,
1217            },
1218        );
1219
1220        let issue = report.errors().first().expect("expected one issue");
1221        assert_eq!(issue.error_code, Some("E021"));
1222        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
1223        assert_eq!(issue.element_index, Some(2));
1224        assert_eq!(issue.component_index, Some(1));
1225    }
1226
1227    #[test]
1228    fn profile_pack_lenient_collects_profile_rule_issues() {
1229        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
1230        let segments = crate::from_bytes(input)
1231            .collect::<Result<Vec<_>, _>>()
1232            .expect("expected parse success");
1233
1234        let ctx = ValidationContext::builder()
1235            .with_profile_pack(demo_orders_profile_pack())
1236            .build();
1237
1238        let report = ctx.validate_lenient(&segments);
1239        assert!(report.has_errors());
1240        assert!(
1241            report
1242                .errors()
1243                .iter()
1244                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
1245        );
1246        assert!(
1247            report
1248                .warnings()
1249                .iter()
1250                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
1251        );
1252    }
1253
1254    #[test]
1255    fn profile_pack_strict_fails_when_profile_errors_exist() {
1256        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
1257        let segments = crate::from_bytes(input)
1258            .collect::<Result<Vec<_>, _>>()
1259            .expect("expected parse success");
1260
1261        let ctx = ValidationContext::builder()
1262            .with_profile_pack(demo_orders_profile_pack())
1263            .build();
1264        let result = ctx.validate_strict(&segments);
1265        assert!(result.is_err());
1266        assert!(result.unwrap_err().has_errors());
1267    }
1268}