Skip to main content

edifact_rs/
validator.rs

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