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`] and
12/// the message reference (UNH element 0) via [`ValidationRuleContext::message_ref`].
13///
14/// # Example
15///
16/// ```rust,ignore
17/// let pack = ProfileRulePack::new("AHB-11001")
18///     .with_rule_fn(|segs, ctx, issues| {
19///         // Rule closures return `()` and push into `issues`;
20///         // use `let else` to skip when metadata is absent.
21///         let Some(pruefid) = ctx.metadata::<Pruefid>() else { return };
22///         let msg_ref = ctx.message_ref.unwrap_or("<unknown>");
23///         // use pruefid and msg_ref …
24///     });
25///
26/// let report = ValidationContext::builder()
27///     .with_profile_pack(pack)
28///     .with_message_ref("0001")
29///     .build()
30///     .validate_lenient_with(&segments, &my_pruefid);
31/// ```
32#[derive(Clone, Copy)]
33pub struct ValidationRuleContext<'a> {
34    metadata: Option<&'a (dyn Any + Send + Sync)>,
35    /// Message reference (`UNH` element 0) for this validation call.
36    ///
37    /// Set at build time via [`ValidationContextBuilder::with_message_ref`].  The reference
38    /// is forwarded automatically into every [`ValidationRuleContext`] constructed by
39    /// [`ValidationContext::validate_lenient`] and related methods.  `None` when no
40    /// reference was configured.
41    pub message_ref: Option<&'a str>,
42}
43
44impl<'a> ValidationRuleContext<'a> {
45    /// Construct a context with no metadata and no message reference.
46    pub fn empty() -> Self {
47        Self {
48            metadata: None,
49            message_ref: None,
50        }
51    }
52
53    /// Construct a context holding a typed metadata reference.
54    pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
55        Self {
56            metadata: Some(value as &(dyn Any + Send + Sync)),
57            message_ref: None,
58        }
59    }
60
61    /// Attach a message reference to this context (builder-style).
62    pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
63        self.message_ref = Some(msg_ref);
64        self
65    }
66
67    /// Downcast the metadata to `T`.  Returns `None` if no metadata was
68    /// injected or if the concrete type does not match `T`.
69    pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
70        self.metadata?.downcast_ref::<T>()
71    }
72
73    /// Return `true` if metadata was provided.
74    pub fn has_metadata(&self) -> bool {
75        self.metadata.is_some()
76    }
77}
78
79impl std::fmt::Debug for ValidationRuleContext<'_> {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("ValidationRuleContext")
82            .field("has_metadata", &self.metadata.is_some())
83            .field("message_ref", &self.message_ref)
84            .finish()
85    }
86}
87
88/// A profile rule that can be added to a [`ProfileRulePack`].
89///
90/// Implement this trait to create reusable, composable profile rules for
91/// EDIFACT message validation.  Rules receive a [`ValidationRuleContext`] that
92/// provides optional typed metadata injected at validation call time via
93/// [`ValidationContext::validate_lenient_with`].
94///
95/// # Multiple issues per invocation
96///
97/// [`evaluate`](ProfileRule::evaluate) appends issues into a caller-supplied
98/// `Vec` rather than returning a single `Option`.  This lets one rule iterate
99/// every matching segment and report *all* violations — not just the first.
100///
101/// Rules that only ever emit a single issue can still return early:
102///
103/// ```rust,ignore
104/// fn evaluate(&self, segments: &[Segment<'_>], _ctx: &ValidationRuleContext<'_>,
105///             issues: &mut Vec<ValidationIssue>) {
106///     if let Some(problem) = check_something(segments) {
107///         issues.push(problem);
108///     }
109/// }
110/// ```
111///
112/// # `bail_on_first_error` interaction
113///
114/// When [`ProfileRulePack::bail_on_first_error`] is set, the pack stops calling
115/// further rules as soon as this method pushes at least one error-severity issue.
116/// Issues already pushed remain in the report; subsequent rules in the same pack
117/// are skipped.
118pub trait ProfileRule: Send + Sync {
119    /// Evaluate the rule against the given segments.
120    ///
121    /// Push any violations into `issues`.  Push nothing if the segments pass.
122    fn evaluate(
123        &self,
124        segments: &[Segment<'_>],
125        context: &ValidationRuleContext<'_>,
126        issues: &mut Vec<ValidationIssue>,
127    );
128}
129
130/// Wraps a context-aware closure as a [`ProfileRule`].
131struct ClosureProfileRule<F>(F);
132
133impl<F> ProfileRule for ClosureProfileRule<F>
134where
135    F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
136        + Send
137        + Sync,
138{
139    fn evaluate(
140        &self,
141        segments: &[Segment<'_>],
142        context: &ValidationRuleContext<'_>,
143        issues: &mut Vec<ValidationIssue>,
144    ) {
145        (self.0)(segments, context, issues);
146    }
147}
148
149/// Wraps a context-free closure as a [`ProfileRule`] (ignores the context parameter).
150struct StatelessClosureProfileRule<F>(F);
151
152impl<F> ProfileRule for StatelessClosureProfileRule<F>
153where
154    F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync,
155{
156    fn evaluate(
157        &self,
158        segments: &[Segment<'_>],
159        _context: &ValidationRuleContext<'_>,
160        issues: &mut Vec<ValidationIssue>,
161    ) {
162        (self.0)(segments, issues);
163    }
164}
165
166/// A rule entry inside a [`ProfileRulePack`], optionally carrying a stable identifier.
167///
168/// The `id` is used by [`ProfileRulePack::merge_with_override`] to de-duplicate rules:
169/// when two packs contain a rule with the same id, the rule from the *other* (override)
170/// pack replaces the one in `self`.
171struct NamedRule {
172    /// Stable identifier for this rule, e.g. `"AHB-11001-BGM-M"`.
173    ///
174    /// `None` for anonymous rules that can never be overridden by id.
175    id: Option<Arc<str>>,
176    rule: Arc<dyn ProfileRule + Send + Sync>,
177}
178
179impl Clone for NamedRule {
180    fn clone(&self) -> Self {
181        Self {
182            id: self.id.clone(),
183            rule: Arc::clone(&self.rule),
184        }
185    }
186}
187
188/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
189pub struct ProfileRulePack {
190    name: String,
191    /// Set of EDIFACT message types this pack is scoped to (e.g. `"ORDERS"`, `"INVOIC"`).
192    ///
193    /// `BTreeSet` provides O(log n) membership tests and deterministic iteration order
194    /// without requiring the `hashbrown` dependency.  Profile packs rarely contain more
195    /// than a handful of types, so the difference over a `Vec` is negligible in practice,
196    /// but the semantics (no duplicates, sorted iteration) are more correct.
197    message_types: std::collections::BTreeSet<String>,
198    /// Association-assigned code (DE 0057) this pack is bound to, e.g. `"5.5.3a"`.
199    ///
200    /// `None` means the pack applies universally regardless of association code.
201    release: Option<String>,
202    rules: Vec<NamedRule>,
203    bail_on_first_error: bool,
204}
205
206impl ProfileRulePack {
207    /// Create an empty rule pack.
208    pub fn new(name: impl Into<String>) -> Self {
209        Self {
210            name: name.into(),
211            message_types: std::collections::BTreeSet::new(),
212            release: None,
213            rules: Vec::new(),
214            bail_on_first_error: false,
215        }
216    }
217
218    /// Return the pack name.
219    pub fn name(&self) -> &str {
220        &self.name
221    }
222
223    /// Return the message types this pack is scoped to.
224    pub fn message_types(&self) -> impl Iterator<Item = &str> {
225        self.message_types.iter().map(|s| s.as_str())
226    }
227
228    /// Return the number of rules in this pack.
229    pub fn rule_count(&self) -> usize {
230        self.rules.len()
231    }
232
233    /// Iterate over the stable identifiers of all **named** rules in this pack.
234    ///
235    /// Anonymous rules (added without an id) are skipped.
236    pub fn rule_ids(&self) -> impl Iterator<Item = &str> {
237        self.rules.iter().filter_map(|r| r.id.as_deref())
238    }
239
240    /// Return the association-assigned release code this pack is bound to, if any.
241    ///
242    /// `None` means the pack applies to messages of any association code.
243    pub fn release(&self) -> Option<&str> {
244        self.release.as_deref()
245    }
246
247    /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
248    ///
249    /// When a pack has one or more message-type restrictions, its rules are only evaluated
250    /// against messages whose `UNH` element 1, component 0 matches one of the registered
251    /// types (e.g. `"ORDERS"`, `"INVOIC"`).
252    ///
253    /// # Silent-skip behaviour
254    ///
255    /// If the input segments do not contain a `UNH` segment, or if the `UNH` message-type
256    /// element is absent, the pack will **silently skip all rules** rather than returning an
257    /// error.  This is intentional: without a readable message type the pack cannot
258    /// determine whether its rules apply, so it errs on the side of no false positives.
259    ///
260    /// If you need a hard failure on a missing `UNH`, add a dedicated [`ProfileRule`] that
261    /// checks for the segment's presence before other rules run.
262    pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
263        self.message_types.insert(message_type.into());
264        self
265    }
266
267    /// Bind this pack to a specific association-assigned code (DE 0057).
268    ///
269    /// When a release is set, rules are only evaluated against messages whose
270    /// `UNH` element 1, component 4 matches `release` exactly (e.g. `"5.5.3a"`).
271    /// Packs with no bound release are universal — they run for every message
272    /// regardless of its association code.
273    ///
274    /// # Example
275    ///
276    /// ```rust,ignore
277    /// let pack = ProfileRulePack::new("UTILMD-5.5.3a")
278    ///     .for_message_type("UTILMD")
279    ///     .for_release("5.5.3a");
280    /// ```
281    pub fn for_release(mut self, release: impl Into<String>) -> Self {
282        self.release = Some(release.into());
283        self
284    }
285
286    /// Stop evaluating rules in this pack after the first `Error`- or `Critical`-severity
287    /// finding.
288    ///
289    /// Bail applies *per pack*, not globally — other packs in the
290    /// [`ValidationContext`] still run even when this pack bails early.  This
291    /// avoids flooding validation reports with cascading false positives when a
292    /// mandatory segment is missing and all subsequent rules reference its content.
293    pub fn bail_on_first_error(mut self, bail: bool) -> Self {
294        self.bail_on_first_error = bail;
295        self
296    }
297
298    /// Add a context-aware rule closure.
299    ///
300    /// The closure receives the segment slice, a [`ValidationRuleContext`], and a
301    /// `&mut Vec<ValidationIssue>` to push any violations into.  Push nothing if
302    /// the segments pass.  Multiple issues may be pushed per invocation.
303    ///
304    /// For rules that do not need context, use [`with_stateless_rule_fn`][Self::with_stateless_rule_fn].
305    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
306    where
307        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
308            + Send
309            + Sync
310            + 'static,
311    {
312        self.rules.push(NamedRule {
313            id: None,
314            rule: Arc::new(ClosureProfileRule(rule)),
315        });
316        self
317    }
318
319    /// Add a context-aware rule closure with a stable identifier.
320    ///
321    /// The `id` is used by [`merge_with_override`][Self::merge_with_override] to de-duplicate
322    /// rules across packs: if `other` has a rule with the same `id`, it replaces the
323    /// corresponding rule in `self`.
324    pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
325    where
326        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
327            + Send
328            + Sync
329            + 'static,
330    {
331        self.rules.push(NamedRule {
332            id: Some(id.into()),
333            rule: Arc::new(ClosureProfileRule(rule)),
334        });
335        self
336    }
337
338    /// Add a context-free rule closure.
339    ///
340    /// The closure receives the segment slice and a `&mut Vec<ValidationIssue>` to
341    /// push violations into.  Convenience wrapper for rules that do not inspect the
342    /// [`ValidationRuleContext`].
343    pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
344    where
345        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
346    {
347        self.rules.push(NamedRule {
348            id: None,
349            rule: Arc::new(StatelessClosureProfileRule(rule)),
350        });
351        self
352    }
353
354    /// Add a context-free rule closure with a stable identifier.
355    ///
356    /// See [`with_named_rule_fn`][Self::with_named_rule_fn] for override semantics.
357    pub fn with_named_stateless_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
358    where
359        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
360    {
361        self.rules.push(NamedRule {
362            id: Some(id.into()),
363            rule: Arc::new(StatelessClosureProfileRule(rule)),
364        });
365        self
366    }
367
368    /// Add a rule that implements [`ProfileRule`].
369    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
370        self.rules.push(NamedRule {
371            id: None,
372            rule: Arc::new(rule),
373        });
374        self
375    }
376
377    /// Add a named rule that implements [`ProfileRule`].
378    ///
379    /// See [`with_named_rule_fn`][Self::with_named_rule_fn] for override semantics.
380    pub fn with_named_rule(
381        mut self,
382        id: impl Into<Arc<str>>,
383        rule: impl ProfileRule + 'static,
384    ) -> Self {
385        self.rules.push(NamedRule {
386            id: Some(id.into()),
387            rule: Arc::new(rule),
388        });
389        self
390    }
391
392    /// Prepend all rules from `base` to this pack.
393    ///
394    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
395    /// Message-type restrictions from `base` are also merged.  The resulting
396    /// release scope must be compatible with both packs: if one pack is scoped
397    /// to a release and the other is not, the scope is preserved; if both are
398    /// scoped, they must match.
399    ///
400    /// # Errors
401    ///
402    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
403    /// different release scopes.  Use
404    /// [`merge_unchecked`][Self::merge_unchecked] in code-generated or
405    /// build-verified contexts where compatibility is guaranteed.
406    ///
407    /// # Example
408    ///
409    /// ```rust,ignore
410    /// let base = ProfileRulePack::new("MIG-UTILMD-BASE")
411    ///     .with_stateless_rule_fn(/* mandatory segment rules */);
412    ///
413    /// let ahb_11001 = ProfileRulePack::new("AHB-11001")
414    ///     .extend_from(&base)?
415    ///     .with_stateless_rule_fn(/* 11001-specific rules */);
416    /// ```
417    pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
418        let mut combined = base.rules.clone();
419        combined.append(&mut self.rules);
420        self.rules = combined;
421        for mt in &base.message_types {
422            self.message_types.insert(mt.clone());
423        }
424        self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
425        Ok(self)
426    }
427
428    /// Merge two packs into one combined pack.
429    ///
430    /// Rules from `self` run before rules from `other`.  If both packs contain
431    /// named rules with the same id, **both run** — use
432    /// [`merge_with_override`][Self::merge_with_override] to de-duplicate by id instead.
433    ///
434    /// # Errors
435    ///
436    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
437    /// different release scopes.  Use
438    /// [`merge_unchecked`][Self::merge_unchecked] in code-generated or
439    /// build-verified contexts where compatibility is guaranteed.
440    pub fn merge(mut self, mut other: Self) -> Result<Self, EdifactError> {
441        self.message_types.append(&mut other.message_types);
442        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
443        self.rules.append(&mut other.rules);
444        Ok(self)
445    }
446
447    /// Merge two packs without checking release-scope compatibility.
448    ///
449    /// Identical to [`merge`][Self::merge] except that incompatible release
450    /// scopes do **not** return `Err` — `other`'s release takes precedence when
451    /// both packs specify different values.
452    ///
453    /// Use this in code-generated profiles where compatibility is guaranteed at
454    /// build time and the fallible `Result` return of [`merge`][Self::merge]
455    /// would only add noise.
456    pub fn merge_unchecked(mut self, mut other: Self) -> Self {
457        self.message_types.append(&mut other.message_types);
458        // Let the incoming release win; `None` defers to whichever side has a value.
459        self.release = match (self.release.take(), other.release.take()) {
460            (_, Some(r)) => Some(r),
461            (current, None) => current,
462        };
463        self.rules.append(&mut other.rules);
464        self
465    }
466
467    /// Merge `other` into `self`, with `other` taking precedence for any rule
468    /// whose id already exists in `self`.
469    ///
470    /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
471    ///   the rule at the same position in `self`.
472    /// - Rules in `other` with no id, or with an id not present in `self`, are
473    ///   **appended** to `self`.
474    /// - Rules present only in `self` (no matching override in `other`) are
475    ///   **retained unchanged**.
476    ///
477    /// Message-type restrictions from `other` are merged into `self`.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
482    /// different release scopes.
483    ///
484    /// # Example
485    ///
486    /// ```rust,ignore
487    /// let base = ProfileRulePack::new("UTILMD-5.4")
488    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* old */ });
489    ///
490    /// let delta = ProfileRulePack::new("UTILMD-5.5-delta")
491    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* updated */ });
492    ///
493    /// // `result` runs the updated BGM-M rule only once:
494    /// let result = base.merge_with_override(delta)?;
495    /// assert_eq!(result.rule_count(), 1);
496    /// ```
497    pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
498        // Build an id→index map for self.rules to avoid O(n*m) behavior.
499        let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
500        for (idx, rule) in self.rules.iter().enumerate() {
501            if let Some(id) = &rule.id {
502                id_to_index.insert(id.clone(), idx);
503            }
504        }
505
506        // Process overrides in a single pass: collect replacements and appends.
507        let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
508        let mut to_append = Vec::new();
509
510        for other_rule in other.rules.drain(..) {
511            if let Some(id) = &other_rule.id {
512                if let Some(&idx) = id_to_index.get(id) {
513                    replacements.push((idx, other_rule));
514                } else {
515                    to_append.push(other_rule);
516                }
517            } else {
518                to_append.push(other_rule);
519            }
520        }
521
522        // Apply replacements in-place.
523        for (idx, rule) in replacements {
524            if idx < self.rules.len() {
525                self.rules[idx] = rule;
526            }
527        }
528
529        // Append new rules.
530        self.rules.append(&mut to_append);
531
532        self.message_types.append(&mut other.message_types);
533        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
534        Ok(self)
535    }
536}
537
538fn merge_release_scopes(
539    current: Option<String>,
540    incoming: Option<String>,
541) -> Result<Option<String>, EdifactError> {
542    match (current, incoming) {
543        (Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
544            current: x,
545            incoming: y,
546        }),
547        (Some(x), Some(_)) => Ok(Some(x)),
548        (Some(x), None) => Ok(Some(x)),
549        (None, incoming) => Ok(incoming),
550    }
551}
552
553impl Validator for ProfileRulePack {
554    fn validate_batch(
555        &self,
556        segments: &[Segment<'_>],
557        report: &mut ValidationReport,
558        context: &ValidationRuleContext<'_>,
559    ) {
560        let unh = segments.iter().find(|segment| segment.tag == "UNH");
561
562        // Cache UNH element 1 to avoid two separate get_element(1) calls (F-019).
563        let unh_e1 = unh.and_then(|s| s.get_element(1));
564
565        // Message-type filter: skip if no registered type matches.
566        let message_type = unh_e1.and_then(|e| e.get_component(0));
567        if !self.message_types.is_empty()
568            && !message_type.is_some_and(|mt| self.message_types.contains(mt))
569        {
570            return;
571        }
572
573        // Release filter: skip if pack is bound to a specific association code that
574        // does not match the message's UNH DE 0057 (element 1, component 4).
575        if let Some(bound_release) = &self.release {
576            let msg_association = unh_e1.and_then(|e| e.get_component(4));
577            if msg_association != Some(bound_release.as_str()) {
578                return;
579            }
580        }
581
582        // Reusable buffer: avoids a heap allocation per rule invocation on the
583        // no-violation fast path.
584        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
585
586        for named in &self.rules {
587            let errors_before = report.errors.len();
588            named.rule.evaluate(segments, context, &mut rule_issues);
589            for issue in rule_issues.drain(..) {
590                match issue.severity {
591                    ValidationSeverity::Critical | ValidationSeverity::Error => {
592                        report.add_error(issue);
593                    }
594                    ValidationSeverity::Warning => {
595                        report.add_warning(issue);
596                    }
597                    ValidationSeverity::Info => {
598                        report.add_info(issue);
599                    }
600                }
601            }
602            // bail_on_first_error fires at rule-invocation granularity: if this rule
603            // pushed at least one error-severity issue, skip remaining rules.
604            if self.bail_on_first_error && report.errors.len() > errors_before {
605                return;
606            }
607        }
608    }
609}
610
611impl std::fmt::Debug for ProfileRulePack {
612    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613        f.debug_struct("ProfileRulePack")
614            .field("name", &self.name)
615            .field("message_types", &self.message_types)
616            .field("release", &self.release)
617            .field("rule_count", &self.rules.len())
618            .field("bail_on_first_error", &self.bail_on_first_error)
619            .finish()
620    }
621}
622
623/// Validation layers used by [`ValidationContext`].
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625#[non_exhaustive]
626pub enum ValidationLayer {
627    /// Interchange / message envelope checks (`UNB`/`UNH`/`UNT`/`UNZ` counts).
628    Envelope,
629    /// Directory structure checks (segment presence/order/arity).
630    Structure,
631    /// Directory code-list checks.
632    CodeList,
633    /// Downstream profile-pack checks.
634    Profile,
635}
636
637struct LayeredValidator {
638    layer: ValidationLayer,
639    validator: Box<dyn Validator + Send + Sync>,
640}
641
642/// Runtime validation context for progressive layered validation.
643pub struct ValidationContext {
644    validators: Vec<LayeredValidator>,
645    envelope_enabled: bool,
646    structure_enabled: bool,
647    code_list_enabled: bool,
648    profile_enabled: bool,
649    message_type: Option<String>,
650    /// Injected into every emitted `ValidationIssue` when set.
651    message_ref: Option<String>,
652    metadata: Option<Arc<dyn Any + Send + Sync>>,
653}
654
655/// Builder for [`ValidationContext`].
656#[must_use = "call `.build()` to produce a `ValidationContext`"]
657pub struct ValidationContextBuilder {
658    inner: ValidationContext,
659}
660
661impl Default for ValidationContextBuilder {
662    /// Default context builder.
663    ///
664    /// Structure, code-list, and profile layers are enabled by default.
665    /// The envelope layer is **disabled** by default; call
666    /// [`ValidationContextBuilder::with_envelope_validation`] to enable it.
667    fn default() -> Self {
668        Self::new()
669    }
670}
671
672impl ValidationContextBuilder {
673    /// Create a new context builder.
674    ///
675    /// Structure, code-list, and profile layers are enabled by default.
676    /// The envelope layer is **disabled** by default; call
677    /// [`with_envelope_validation`][Self::with_envelope_validation] to enable it
678    /// and add the built-in [`EnvelopeValidator`] in one step.
679    pub fn new() -> Self {
680        Self {
681            inner: ValidationContext {
682                validators: Vec::new(),
683                envelope_enabled: false,
684                structure_enabled: true,
685                code_list_enabled: true,
686                profile_enabled: true,
687                message_type: None,
688                message_ref: None,
689                metadata: None,
690            },
691        }
692    }
693
694    /// Attach typed metadata accessible to context-aware profile rules.
695    ///
696    /// Rules added with [`ProfileRulePack::with_rule_fn`] receive the metadata
697    /// via [`ValidationRuleContext::metadata`] on every call to
698    /// [`ValidationContext::validate_lenient`].
699    ///
700    /// For per-call metadata that varies between validation invocations, use
701    /// [`ValidationContext::validate_lenient_with`] instead.
702    pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
703        self.inner.metadata = Some(Arc::new(value));
704        self
705    }
706
707    /// Stamp every issue produced by this context with the given message reference.
708    ///
709    /// The message reference corresponds to DE 0062 from the `UNH` segment.
710    /// Use this when validating individual messages from a multi-message
711    /// interchange so that issues in the resulting [`ValidationReport`] can be
712    /// correlated back to the originating `UNH`/`UNT` envelope.
713    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
714        self.inner.message_ref = Some(message_ref.into());
715        self
716    }
717
718    /// Set message type metadata for downstream validators.
719    pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
720        self.inner.message_type = Some(message_type.into());
721        let configured = self.inner.message_type.as_deref();
722        for layered in &mut self.inner.validators {
723            layered.validator.set_message_type(configured);
724        }
725        self
726    }
727
728    /// Enable/disable structure validators.
729    pub fn structure(mut self, enabled: bool) -> Self {
730        self.inner.structure_enabled = enabled;
731        self
732    }
733
734    /// Enable/disable code-list validators.
735    pub fn code_list(mut self, enabled: bool) -> Self {
736        self.inner.code_list_enabled = enabled;
737        self
738    }
739
740    /// Enable/disable profile validators.
741    pub fn profile(mut self, enabled: bool) -> Self {
742        self.inner.profile_enabled = enabled;
743        self
744    }
745
746    /// Enable/disable envelope layer validators.
747    ///
748    /// Off by default.  Call [`with_envelope_validation`][Self::with_envelope_validation]
749    /// to add the built-in [`EnvelopeValidator`] and enable the layer in one step.
750    pub fn envelope(mut self, enabled: bool) -> Self {
751        self.inner.envelope_enabled = enabled;
752        self
753    }
754
755    /// Add the built-in [`EnvelopeValidator`] and enable the envelope layer.
756    ///
757    /// The built-in validator mirrors [`crate::validate_envelope`] but
758    /// translates each structural error into a [`ValidationIssue`] so all
759    /// issues land in the unified [`ValidationReport`] alongside profile and
760    /// directory findings.
761    ///
762    /// # Example
763    ///
764    /// ```rust,ignore
765    /// let report = ValidationContext::builder()
766    ///     .with_envelope_validation()
767    ///     .with_message_type("ORDERS")
768    ///     .build()
769    ///     .validate_lenient(&all_segments);
770    /// ```
771    pub fn with_envelope_validation(mut self) -> Self {
772        self.inner.envelope_enabled = true;
773        self.inner.validators.push(LayeredValidator {
774            layer: ValidationLayer::Envelope,
775            validator: Box::new(EnvelopeValidator),
776        });
777        self
778    }
779
780    /// Add a validator assigned to `layer`.
781    pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
782    where
783        V: Validator + 'static,
784    {
785        validator.set_message_type(self.inner.message_type.as_deref());
786        self.inner.validators.push(LayeredValidator {
787            layer,
788            validator: Box::new(validator),
789        });
790        self
791    }
792
793    /// Add a profile rule pack to the profile layer.
794    pub fn with_profile_pack(mut self, mut pack: ProfileRulePack) -> Self {
795        pack.set_message_type(self.inner.message_type.as_deref());
796        self.inner.validators.push(LayeredValidator {
797            layer: ValidationLayer::Profile,
798            validator: Box::new(pack),
799        });
800        self
801    }
802
803    /// Finalize builder and create context.
804    #[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
805    pub fn build(self) -> ValidationContext {
806        self.inner
807    }
808}
809
810impl ValidationContext {
811    /// Start building a validation context.
812    pub fn builder() -> ValidationContextBuilder {
813        ValidationContextBuilder::new()
814    }
815
816    /// Execute validators in lenient mode for enabled layers.
817    ///
818    /// Uses any metadata set via [`ValidationContextBuilder::with_metadata`].
819    /// For per-call metadata, use [`validate_lenient_with`][Self::validate_lenient_with].
820    pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
821        self.validate_with_context(segments, &self.build_rule_context())
822    }
823
824    /// Execute validators with per-call typed metadata.
825    ///
826    /// The metadata is accessible inside context-aware rule closures via
827    /// [`ValidationRuleContext::metadata`].  This is the recommended path when
828    /// a single [`ProfileRulePack`] serves multiple process-variant contexts
829    /// (e.g., one pack per message type, injecting the Pruefidentifikator at
830    /// call time).
831    pub fn validate_lenient_with<T: Any + Send + Sync>(
832        &self,
833        segments: &[Segment<'_>],
834        value: &T,
835    ) -> ValidationReport {
836        let ctx = ValidationRuleContext {
837            metadata: Some(value as &(dyn Any + Send + Sync)),
838            message_ref: self.message_ref.as_deref(),
839        };
840        self.validate_with_context(segments, &ctx)
841    }
842
843    /// Execute validators in strict mode for enabled layers.
844    ///
845    /// Returns `Ok(report)` when validation produces no errors.  The `Err` variant
846    /// **also contains the full report** (errors, warnings, and infos) so that
847    /// callers can inspect all issues even on failure.
848    ///
849    /// Warnings do **not** cause this method to return `Err`.  Call
850    /// [`validate_lenient`][Self::validate_lenient] if you want to inspect warnings
851    /// without failing on errors.
852    pub fn validate_strict(
853        &self,
854        segments: &[Segment<'_>],
855    ) -> Result<ValidationReport, ValidationReport> {
856        self.validate_lenient(segments).result()
857    }
858
859    /// Execute validators in strict mode with per-call typed metadata.
860    ///
861    /// See [`validate_lenient_with`][Self::validate_lenient_with] for context usage and
862    /// [`validate_strict`][Self::validate_strict] for strict-mode semantics.
863    pub fn validate_strict_with<T: Any + Send + Sync>(
864        &self,
865        segments: &[Segment<'_>],
866        value: &T,
867    ) -> Result<ValidationReport, ValidationReport> {
868        self.validate_lenient_with(segments, value).result()
869    }
870
871    /// Execute validators in lenient mode against an owned-segment slice.
872    ///
873    /// Avoids building a `Vec<Segment<'_>>` for the entire slice up front.
874    /// Instead, segments are converted to `Segment<'_>` on demand, per validator
875    /// layer:
876    ///
877    /// - **Envelope layer**: converts the full slice once (`O(n)` allocations).
878    /// - **Non-envelope layers after envelope ran**: converts only the
879    ///   non-service segments (UNB/UNZ/UNG/UNE filtered out) — also `O(n)` but
880    ///   a smaller constant.
881    /// - **Non-envelope layers when no envelope ran**: converts the full slice
882    ///   once, shared across all remaining layers via a lazy `OnceCell`.
883    ///
884    /// When no layers are enabled this returns an empty [`ValidationReport`]
885    /// without any allocation.
886    pub fn validate_lenient_owned(&self, segments: &[OwnedSegment]) -> ValidationReport {
887        if self.validators.is_empty()
888            && !self.envelope_enabled
889            && !self.structure_enabled
890            && !self.code_list_enabled
891            && !self.profile_enabled
892        {
893            return ValidationReport::default();
894        }
895        self.validate_with_context_owned(segments, &self.build_rule_context())
896    }
897
898    fn build_rule_context(&self) -> ValidationRuleContext<'_> {
899        self.metadata
900            .as_ref()
901            .map(|arc| ValidationRuleContext {
902                metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
903                message_ref: self.message_ref.as_deref(),
904            })
905            .unwrap_or_else(|| ValidationRuleContext {
906                metadata: None,
907                message_ref: self.message_ref.as_deref(),
908            })
909    }
910
911    /// Internal: validate owned segments without the upfront full-slice
912    /// `as_borrowed()` conversion that `validate_lenient_owned` used to require.
913    fn validate_with_context_owned(
914        &self,
915        segments: &[OwnedSegment],
916        context: &ValidationRuleContext<'_>,
917    ) -> ValidationReport {
918        let mut report = ValidationReport::default();
919        // Lazy full-slice borrow — built only when the first non-envelope
920        // validator needs it (i.e. when no envelope validator ran).
921        let mut full_borrowed: Option<Vec<Segment<'_>>> = None;
922        // Lazy filtered borrow — built once when the first non-envelope
923        // validator runs after an envelope pass.
924        let mut filtered_borrowed: Option<Vec<Segment<'_>>> = None;
925        let mut envelope_ran = false;
926
927        for lv in &self.validators {
928            if !self.layer_enabled(lv.layer) {
929                continue;
930            }
931            if lv.layer == ValidationLayer::Envelope {
932                // Convert full slice for envelope validation.
933                let borrowed: Vec<Segment<'_>> = segments.iter().map(|s| s.as_borrowed()).collect();
934                lv.validator.validate_batch(&borrowed, &mut report, context);
935                envelope_ran = true;
936            } else if envelope_ran {
937                // Use filtered slice (no service segments).
938                let active = filtered_borrowed.get_or_insert_with(|| {
939                    segments
940                        .iter()
941                        .filter(|s| !matches!(s.tag.as_str(), "UNB" | "UNZ" | "UNG" | "UNE"))
942                        .map(|s| s.as_borrowed())
943                        .collect()
944                });
945                lv.validator.validate_batch(active, &mut report, context);
946            } else {
947                // No envelope pass yet — use the full slice, lazily converted.
948                let active = full_borrowed
949                    .get_or_insert_with(|| segments.iter().map(|s| s.as_borrowed()).collect());
950                lv.validator.validate_batch(active, &mut report, context);
951            }
952        }
953
954        // Stamp every issue with the message reference if one was configured.
955        if let Some(ref msg_ref) = self.message_ref {
956            for issue in report
957                .errors
958                .iter_mut()
959                .chain(report.warnings.iter_mut())
960                .chain(report.infos.iter_mut())
961            {
962                if issue.message_ref.is_none() {
963                    issue.message_ref = Some(msg_ref.clone());
964                }
965            }
966        }
967        report
968    }
969
970    /// Execute validators in strict mode against an owned-segment slice.
971    ///
972    /// Equivalent to [`validate_strict`][Self::validate_strict] but accepts
973    /// `&[OwnedSegment]` directly, avoiding a manual `.as_borrowed()` conversion
974    /// at the call site.
975    pub fn validate_strict_owned(
976        &self,
977        segments: &[OwnedSegment],
978    ) -> Result<ValidationReport, ValidationReport> {
979        self.validate_lenient_owned(segments).result()
980    }
981
982    fn validate_with_context(
983        &self,
984        segments: &[Segment<'_>],
985        context: &ValidationRuleContext<'_>,
986    ) -> ValidationReport {
987        let mut report = ValidationReport::default();
988        // After the envelope layer runs, strip the interchange / functional-group
989        // service segments so they do not reach structure / profile validators that
990        // only understand message-level segments.  The allocation is deferred until
991        // the envelope layer is actually present and enabled.
992        let mut filtered: Option<Vec<Segment<'_>>> = None;
993        let mut envelope_ran = false;
994
995        for lv in &self.validators {
996            if !self.layer_enabled(lv.layer) {
997                continue;
998            }
999            // For the envelope layer always use the full unmodified slice.
1000            if lv.layer == ValidationLayer::Envelope {
1001                lv.validator.validate_batch(segments, &mut report, context);
1002                envelope_ran = true;
1003            } else {
1004                // For every other layer: if the envelope ran, use the filtered
1005                // slice that has UNB/UNZ/UNG/UNE removed; otherwise use the
1006                // original slice unchanged.
1007                let active: &[Segment<'_>] = if envelope_ran {
1008                    filtered.get_or_insert_with(|| {
1009                        segments
1010                            .iter()
1011                            .filter(|s| !matches!(s.tag, "UNB" | "UNZ" | "UNG" | "UNE"))
1012                            .cloned()
1013                            .collect()
1014                    })
1015                } else {
1016                    segments
1017                };
1018                lv.validator.validate_batch(active, &mut report, context);
1019            }
1020        }
1021
1022        // Stamp every issue with the message reference if one was configured.
1023        if let Some(ref msg_ref) = self.message_ref {
1024            for issue in report
1025                .errors
1026                .iter_mut()
1027                .chain(report.warnings.iter_mut())
1028                .chain(report.infos.iter_mut())
1029            {
1030                if issue.message_ref.is_none() {
1031                    issue.message_ref = Some(msg_ref.clone());
1032                }
1033            }
1034        }
1035        report
1036    }
1037
1038    /// Message type metadata associated with this context, if provided.
1039    pub fn message_type(&self) -> Option<&str> {
1040        self.message_type.as_deref()
1041    }
1042
1043    /// Message reference (`UNH` element 0) associated with this context, if provided.
1044    pub fn message_ref(&self) -> Option<&str> {
1045        self.message_ref.as_deref()
1046    }
1047
1048    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
1049        match layer {
1050            ValidationLayer::Envelope => self.envelope_enabled,
1051            ValidationLayer::Structure => self.structure_enabled,
1052            ValidationLayer::CodeList => self.code_list_enabled,
1053            ValidationLayer::Profile => self.profile_enabled,
1054        }
1055    }
1056}
1057
1058/// Pluggable validator for parsed EDIFACT segments.
1059///
1060/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
1061/// entire segment sequence and appends issues to a [`ValidationReport`].
1062///
1063/// Validators receive a [`ValidationRuleContext`] that may carry typed metadata
1064/// injected at validation call time.  Implementations that do not need the
1065/// context may ignore it.
1066///
1067/// For validators that work segment-by-segment, the convenience function
1068/// [`validate_each`] iterates over the slice and calls a per-segment closure,
1069/// so you only need to implement `validate_batch`:
1070///
1071/// ```rust,ignore
1072/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, _ctx: &ValidationRuleContext<'_>) {
1073///     validate_each(segments, report, |seg| {
1074///         // return Ok(()) or Err(EdifactError::...)
1075///         Ok(())
1076///     });
1077/// }
1078/// ```
1079pub trait Validator: Send + Sync {
1080    /// Validate a full segment set and append issues to `report`.
1081    ///
1082    /// Implementations that do not need the context may ignore the `context` parameter.
1083    fn validate_batch(
1084        &self,
1085        segments: &[Segment<'_>],
1086        report: &mut ValidationReport,
1087        context: &ValidationRuleContext<'_>,
1088    );
1089
1090    /// Configure message-type metadata for validators that support explicit scoping.
1091    fn set_message_type(&mut self, _message_type: Option<&str>) {}
1092}
1093
1094/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
1095/// and converts any `Err` into report entries.
1096///
1097/// Use this in `validate_batch` implementations that work segment-by-segment:
1098///
1099/// ```rust,ignore
1100/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
1101///     validate_each(segments, report, |seg| { /* ... */ Ok(()) });
1102/// }
1103/// ```
1104pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
1105where
1106    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
1107{
1108    for segment in segments {
1109        if let Err(err) = f(segment) {
1110            report_error(report, err);
1111        }
1112    }
1113}
1114
1115/// Convert a low-level validation error to a user-facing issue and append it.
1116pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
1117    let issue = issue_from_error(err);
1118    match issue.severity {
1119        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
1120        ValidationSeverity::Warning => report.add_warning(issue),
1121        ValidationSeverity::Info => report.add_info(issue),
1122    }
1123}
1124
1125// ── EnvelopeValidator ─────────────────────────────────────────────────────────
1126
1127/// Built-in validator for EDIFACT interchange envelope structure.
1128///
1129/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
1130/// segment counts.  Registered by
1131/// [`ValidationContextBuilder::with_envelope_validation`].
1132///
1133/// The validator translates each [`EdifactError`] from
1134/// [`crate::validate_envelope`] into a [`ValidationIssue`] so that envelope
1135/// findings appear in the unified [`ValidationReport`] alongside structure and
1136/// profile results.
1137pub struct EnvelopeValidator;
1138
1139impl Validator for EnvelopeValidator {
1140    fn validate_batch(
1141        &self,
1142        segments: &[Segment<'_>],
1143        report: &mut ValidationReport,
1144        _ctx: &ValidationRuleContext<'_>,
1145    ) {
1146        if let Err(e) = crate::envelope::validate_envelope(segments) {
1147            report_error(report, e);
1148        }
1149    }
1150}
1151
1152fn issue_from_error(err: EdifactError) -> ValidationIssue {
1153    let code = err.stable_code();
1154    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
1155    let default_hint = err.recovery_hint();
1156
1157    match err {
1158        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
1159            issue = issue.with_segment(tag).with_offset(offset);
1160        }
1161        EdifactError::InvalidElementCount { tag, offset, .. } => {
1162            issue = issue.with_segment(tag).with_offset(offset);
1163        }
1164        EdifactError::InvalidComponentCount {
1165            tag,
1166            element_index,
1167            offset,
1168            ..
1169        } => {
1170            issue = issue
1171                .with_segment(tag)
1172                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
1173                .with_offset(offset);
1174        }
1175        EdifactError::InvalidCodeValue {
1176            tag,
1177            element_index,
1178            offset,
1179            suggestion,
1180            ..
1181        } => {
1182            issue = issue
1183                .with_segment(tag)
1184                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
1185                .with_offset(offset);
1186            if let Some(s) = suggestion {
1187                issue = issue.with_suggestion(s);
1188            }
1189        }
1190        EdifactError::MissingSegment { tag, .. } => {
1191            issue = issue.with_segment(tag);
1192        }
1193        EdifactError::QualifierMismatch { tag, offset, .. } => {
1194            issue = issue
1195                .with_segment(tag)
1196                .with_element_index(0)
1197                .with_offset(offset);
1198        }
1199        EdifactError::ConditionalRequirementNotMet {
1200            tag,
1201            element_index,
1202            offset,
1203            ..
1204        } => {
1205            issue = issue
1206                .with_segment(tag)
1207                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
1208                .with_offset(offset);
1209        }
1210        EdifactError::MissingRequiredElement { tag, element_index } => {
1211            issue = issue.with_segment(tag);
1212            if let Ok(idx) = u8::try_from(element_index) {
1213                issue = issue.with_element_index(idx);
1214            }
1215        }
1216        EdifactError::MissingRequiredComponent {
1217            tag,
1218            element_index,
1219            component_index,
1220        } => {
1221            issue = issue.with_segment(tag);
1222            if let Ok(ei) = u8::try_from(element_index) {
1223                issue = issue.with_element_index(ei);
1224            }
1225            if let Ok(ci) = u8::try_from(component_index) {
1226                issue = issue.with_component_index(ci);
1227            }
1228        }
1229        EdifactError::InvalidReleaseSequence { offset }
1230        | EdifactError::InvalidDelimiter { offset, .. }
1231        | EdifactError::InvalidText { offset }
1232        | EdifactError::UnexpectedEof { offset }
1233        | EdifactError::UnexpectedDataToken { offset }
1234        | EdifactError::FunctionalGroupNotSupported { offset } => {
1235            issue = issue.with_offset(offset);
1236        }
1237        _ => {}
1238    }
1239
1240    if issue.suggestion.is_none() {
1241        if let Some(hint) = default_hint {
1242            issue = issue.with_suggestion(hint);
1243        }
1244    }
1245
1246    issue
1247}
1248
1249fn severity_for(err: &EdifactError) -> ValidationSeverity {
1250    match err {
1251        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
1252            ValidationSeverity::Warning
1253        }
1254        _ => ValidationSeverity::Error,
1255    }
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260    use super::*;
1261    use crate::model::Element;
1262
1263    fn demo_orders_profile_pack() -> ProfileRulePack {
1264        ProfileRulePack::new("ORDERS-DEMO")
1265            .for_message_type("ORDERS")
1266            .with_stateless_rule_fn(|segments, issues| {
1267                issues.extend((|| -> Option<ValidationIssue> {
1268                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
1269                    let document_code = bgm.get_element(0)?.get_component(0)?;
1270                    (document_code == "220").then(|| {
1271                        ValidationIssue::new(
1272                            ValidationSeverity::Error,
1273                            "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
1274                        )
1275                        .with_rule_id("DEMO-P001")
1276                        .with_segment("BGM")
1277                        .with_element_index(0)
1278                        .with_suggestion("Use a different BGM document code in this demo pack")
1279                    })
1280                })());
1281            })
1282            .with_stateless_rule_fn(|segments, issues| {
1283                issues.extend((|| -> Option<ValidationIssue> {
1284                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
1285                    let reference = bgm.get_element(1)?.get_component(0)?;
1286                    (reference == "PO123").then(|| {
1287                        ValidationIssue::new(
1288                            ValidationSeverity::Warning,
1289                            "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
1290                        )
1291                        .with_rule_id("DEMO-P002")
1292                        .with_segment("BGM")
1293                        .with_element_index(1)
1294                        .with_suggestion("Use a non-reserved reference in this demo pack")
1295                    })
1296                })());
1297            })
1298    }
1299
1300    struct RejectBgm;
1301
1302    struct WarnBgm;
1303
1304    impl Validator for RejectBgm {
1305        fn validate_batch(
1306            &self,
1307            segments: &[Segment<'_>],
1308            report: &mut ValidationReport,
1309            _context: &ValidationRuleContext<'_>,
1310        ) {
1311            validate_each(segments, report, |segment| {
1312                if segment.tag == "BGM" {
1313                    return Err(EdifactError::InvalidSegmentForMessage {
1314                        tag: "BGM".to_owned(),
1315                        message_type: "TEST".to_owned(),
1316                        offset: segment.tag_span.start,
1317                    });
1318                }
1319                Ok(())
1320            });
1321        }
1322    }
1323
1324    impl Validator for WarnBgm {
1325        fn validate_batch(
1326            &self,
1327            segments: &[Segment<'_>],
1328            report: &mut ValidationReport,
1329            _context: &ValidationRuleContext<'_>,
1330        ) {
1331            validate_each(segments, report, |segment| {
1332                if segment.tag == "BGM" {
1333                    return Err(EdifactError::InvalidCodeValue {
1334                        tag: "BGM".to_owned(),
1335                        element_index: 0,
1336                        value: "XXX".to_owned(),
1337                        code_list: "1001".to_owned(),
1338                        offset: segment.span.start,
1339                        suggestion: None,
1340                    });
1341                }
1342                Ok(())
1343            });
1344        }
1345    }
1346
1347    fn test_segment(tag: &'static str) -> Segment<'static> {
1348        Segment {
1349            tag,
1350            span: crate::Span::new(0, 0),
1351            tag_span: crate::Span::new(0, 0),
1352            elements: vec![Element::of(&["x"])],
1353        }
1354    }
1355
1356    #[test]
1357    fn lenient_collects_issues() {
1358        let segments = vec![test_segment("UNH"), test_segment("BGM")];
1359        let mut report = ValidationReport::default();
1360        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
1361        assert!(report.has_errors());
1362        assert_eq!(report.errors().len(), 1);
1363    }
1364
1365    #[test]
1366    fn strict_fails_on_errors() {
1367        let segments = vec![test_segment("BGM")];
1368        let mut report = ValidationReport::default();
1369        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
1370        assert!(report.has_errors());
1371        assert_eq!(report.errors().len(), 1);
1372    }
1373
1374    #[test]
1375    fn context_builder_respects_layer_toggles() {
1376        let segments = vec![test_segment("BGM")];
1377        let ctx = ValidationContext::builder()
1378            .structure(false)
1379            .with_validator(ValidationLayer::Structure, RejectBgm)
1380            .with_validator(ValidationLayer::CodeList, WarnBgm)
1381            .build();
1382
1383        let report = ctx.validate_lenient(&segments);
1384        assert!(!report.has_errors());
1385        assert_eq!(report.warnings().len(), 1);
1386    }
1387
1388    #[test]
1389    fn context_strict_fails_when_structure_enabled() {
1390        let segments = vec![test_segment("BGM")];
1391        let ctx = ValidationContext::builder()
1392            .with_message_type("ORDERS")
1393            .with_validator(ValidationLayer::Structure, RejectBgm)
1394            .build();
1395
1396        assert_eq!(ctx.message_type(), Some("ORDERS"));
1397        let result = ctx.validate_strict(&segments);
1398        assert!(result.is_err());
1399        assert!(result.unwrap_err().has_errors());
1400    }
1401
1402    #[test]
1403    fn report_error_applies_default_recovery_hint() {
1404        let mut report = ValidationReport::default();
1405        report_error(
1406            &mut report,
1407            EdifactError::InvalidReleaseSequence { offset: 9 },
1408        );
1409
1410        let issue = report
1411            .errors()
1412            .first()
1413            .expect("expected one issue in the report");
1414        let hint = issue
1415            .suggestion
1416            .as_deref()
1417            .expect("expected default hint to be set");
1418        assert!(hint.contains("Release character"));
1419        assert_eq!(issue.error_code, Some("E019"));
1420    }
1421
1422    #[test]
1423    fn missing_required_component_maps_metadata_to_issue() {
1424        let mut report = ValidationReport::default();
1425        report_error(
1426            &mut report,
1427            EdifactError::MissingRequiredComponent {
1428                tag: "BGM".to_owned(),
1429                element_index: 2,
1430                component_index: 1,
1431            },
1432        );
1433
1434        let issue = report.errors().first().expect("expected one issue");
1435        assert_eq!(issue.error_code, Some("E021"));
1436        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
1437        assert_eq!(issue.element_index, Some(2));
1438        assert_eq!(issue.component_index, Some(1));
1439    }
1440
1441    #[test]
1442    fn profile_pack_lenient_collects_profile_rule_issues() {
1443        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
1444        let segments = crate::from_bytes(input)
1445            .collect::<Result<Vec<_>, _>>()
1446            .expect("expected parse success");
1447
1448        let ctx = ValidationContext::builder()
1449            .with_profile_pack(demo_orders_profile_pack())
1450            .build();
1451
1452        let report = ctx.validate_lenient(&segments);
1453        assert!(report.has_errors());
1454        assert!(
1455            report
1456                .errors()
1457                .iter()
1458                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
1459        );
1460        assert!(
1461            report
1462                .warnings()
1463                .iter()
1464                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
1465        );
1466    }
1467
1468    #[test]
1469    fn profile_pack_strict_fails_when_profile_errors_exist() {
1470        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
1471        let segments = crate::from_bytes(input)
1472            .collect::<Result<Vec<_>, _>>()
1473            .expect("expected parse success");
1474
1475        let ctx = ValidationContext::builder()
1476            .with_profile_pack(demo_orders_profile_pack())
1477            .build();
1478        let result = ctx.validate_strict(&segments);
1479        assert!(result.is_err());
1480        assert!(result.unwrap_err().has_errors());
1481    }
1482
1483    // ── bail_on_first_error ──────────────────────────────────────────────────
1484
1485    /// A rule that emits two error-severity issues (one per DTM segment).
1486    fn two_dtm_errors_rule() -> ProfileRulePack {
1487        ProfileRulePack::new("TEST-BAIL")
1488            .with_stateless_rule_fn(|segments, issues| {
1489                // Rule A: emits one error per DTM segment.
1490                for seg in segments.iter().filter(|s| s.tag == "DTM") {
1491                    issues.push(
1492                        ValidationIssue::new(
1493                            ValidationSeverity::Error,
1494                            format!("DTM error at offset {}", seg.span.start),
1495                        )
1496                        .with_rule_id("BAIL-R1")
1497                        .with_segment("DTM"),
1498                    );
1499                }
1500            })
1501            .with_stateless_rule_fn(|segments, issues| {
1502                // Rule B: never fires; used to verify bail skips this rule.
1503                for seg in segments.iter().filter(|s| s.tag == "BGM") {
1504                    issues.push(
1505                        ValidationIssue::new(ValidationSeverity::Error, "BGM error")
1506                            .with_rule_id("BAIL-R2")
1507                            .with_segment(seg.tag),
1508                    );
1509                }
1510            })
1511    }
1512
1513    #[test]
1514    fn bail_on_first_error_fires_at_rule_invocation_granularity() {
1515        // Two DTM segments → Rule A emits 2 errors for them.
1516        // With bail, Rule B (BGM check) must NOT run.
1517        let input =
1518            b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
1519        let segments = crate::from_bytes(input)
1520            .collect::<Result<Vec<_>, _>>()
1521            .expect("parse failed");
1522
1523        let pack_with_bail = two_dtm_errors_rule().bail_on_first_error(true);
1524        let ctx = ValidationContext::builder()
1525            .with_profile_pack(pack_with_bail)
1526            .build();
1527        let report = ctx.validate_lenient(&segments);
1528
1529        // Rule A fires: both DTM errors are in the report (the whole rule invocation
1530        // runs to completion before bail is checked).
1531        assert_eq!(
1532            report
1533                .errors()
1534                .iter()
1535                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
1536                .count(),
1537            2,
1538            "both DTM errors from Rule A should be present"
1539        );
1540        // Bail fired after Rule A: Rule B (BGM) must be skipped.
1541        assert_eq!(
1542            report
1543                .errors()
1544                .iter()
1545                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
1546                .count(),
1547            0,
1548            "Rule B should have been skipped by bail"
1549        );
1550    }
1551
1552    #[test]
1553    fn bail_disabled_runs_all_rules() {
1554        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
1555        let segments = crate::from_bytes(input)
1556            .collect::<Result<Vec<_>, _>>()
1557            .expect("parse failed");
1558
1559        let pack_no_bail = two_dtm_errors_rule(); // bail_on_first_error defaults to false
1560        let ctx = ValidationContext::builder()
1561            .with_profile_pack(pack_no_bail)
1562            .build();
1563        let report = ctx.validate_lenient(&segments);
1564
1565        // Both rules run: one DTM error from Rule A, one BGM error from Rule B.
1566        assert_eq!(
1567            report
1568                .errors()
1569                .iter()
1570                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
1571                .count(),
1572            1
1573        );
1574        assert_eq!(
1575            report
1576                .errors()
1577                .iter()
1578                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
1579                .count(),
1580            1
1581        );
1582    }
1583
1584    // ── message_ref in ValidationRuleContext ─────────────────────────────────
1585
1586    #[test]
1587    fn message_ref_is_visible_inside_rule_closure() {
1588        let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
1589        let segments = crate::from_bytes(input)
1590            .collect::<Result<Vec<_>, _>>()
1591            .expect("parse failed");
1592
1593        let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
1594            if let Some(mref) = ctx.message_ref {
1595                issues.push(
1596                    ValidationIssue::new(
1597                        ValidationSeverity::Info,
1598                        format!("validating message {mref}"),
1599                    )
1600                    .with_rule_id("CTX-REF"),
1601                );
1602            }
1603        });
1604
1605        let ctx = ValidationContext::builder()
1606            .with_profile_pack(pack)
1607            .with_message_ref("MSG001")
1608            .build();
1609
1610        let report = ctx.validate_lenient(&segments);
1611        let info = report
1612            .infos()
1613            .iter()
1614            .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
1615            .expect("expected info issue from CTX-REF rule");
1616        assert!(info.message.contains("MSG001"));
1617        // The message_ref is also stamped onto the issue itself.
1618        assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
1619    }
1620}