Skip to main content

edifact_rs/validator/
pack.rs

1//! Profile rule packs: `ProfileRule`, `ProfileRulePack`, and supporting types.
2
3use super::ValidationRuleContext;
4use super::Validator;
5use crate::group::SegmentGroupIndexed;
6use crate::{EdifactError, Segment, ValidationIssue, ValidationReport, ValidationSeverity};
7use std::sync::Arc;
8
9/// A profile rule that can be added to a [`ProfileRulePack`].
10///
11/// Implement this trait to create reusable, composable profile rules for
12/// EDIFACT message validation.  Rules receive a [`ValidationRuleContext`] that
13/// provides optional typed metadata injected at validation call time via
14/// [`super::context::ValidationContext::validate_with`].
15///
16/// # Multiple issues per invocation
17///
18/// [`evaluate`](ProfileRule::evaluate) appends issues into a caller-supplied
19/// `Vec` rather than returning a single `Option`.  This lets one rule iterate
20/// every matching segment and report *all* violations — not just the first.
21///
22/// # `with_bail_on_first_error` interaction
23///
24/// When [`ProfileRulePack::with_bail_on_first_error`] is set, the pack stops calling
25/// further rules as soon as this method pushes at least one error-severity issue.
26/// Issues already pushed remain in the report; subsequent rules in the same pack
27/// are skipped.
28pub trait ProfileRule: Send + Sync {
29    /// Evaluate the rule against the given segments.
30    ///
31    /// Push any violations into `issues`.  Push nothing if the segments pass.
32    fn evaluate(
33        &self,
34        segments: &[Segment<'_>],
35        context: &ValidationRuleContext<'_>,
36        issues: &mut Vec<ValidationIssue>,
37    );
38}
39
40/// Wraps a context-aware closure as a [`ProfileRule`].
41struct ClosureProfileRule<F>(F);
42
43impl<F> ProfileRule for ClosureProfileRule<F>
44where
45    F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
46        + Send
47        + Sync,
48{
49    fn evaluate(
50        &self,
51        segments: &[Segment<'_>],
52        context: &ValidationRuleContext<'_>,
53        issues: &mut Vec<ValidationIssue>,
54    ) {
55        (self.0)(segments, context, issues);
56    }
57}
58
59/// Wraps a context-free closure as a [`ProfileRule`] (ignores the context parameter).
60struct StatelessClosureProfileRule<F>(F);
61
62impl<F> ProfileRule for StatelessClosureProfileRule<F>
63where
64    F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync,
65{
66    fn evaluate(
67        &self,
68        segments: &[Segment<'_>],
69        _context: &ValidationRuleContext<'_>,
70        issues: &mut Vec<ValidationIssue>,
71    ) {
72        (self.0)(segments, issues);
73    }
74}
75
76/// A rule entry inside a [`ProfileRulePack`], optionally carrying a stable identifier.
77///
78/// The `id` is used by [`ProfileRulePack::merge_with_override`] to de-duplicate rules:
79/// when two packs contain a rule with the same id, the rule from the *other* (override)
80/// pack replaces the one in `self`.
81pub(super) struct NamedRule {
82    /// Stable identifier for this rule, e.g. `"PROFILE-4711-BGM-M"`.
83    ///
84    /// `None` for anonymous rules that can never be overridden by id.
85    pub(super) id: Option<Arc<str>>,
86    pub(super) rule: Arc<dyn ProfileRule + Send + Sync>,
87}
88
89impl Clone for NamedRule {
90    fn clone(&self) -> Self {
91        Self {
92            id: self.id.clone(),
93            rule: Arc::clone(&self.rule),
94        }
95    }
96}
97
98/// A group-scoped rule entry inside a [`ProfileRulePack`].
99///
100/// Group rules are evaluated by [`ProfileRulePack`] during a segment-group tree
101/// traversal (see [`ValidationContext::validate_grouped`]).  Each rule
102/// receives the current [`SegmentGroupIndexed`] node, the full message segment
103/// slice, and the validation context.
104///
105/// The `group_scope` field restricts evaluation to groups whose `definition` field
106/// matches: `Some("SG5")` fires only inside `SG5` groups; `None` fires for every
107/// group in the traversal.
108pub(super) struct NamedGroupRule {
109    /// Stable identifier, used for override deduplication.
110    pub(super) id: Option<Arc<str>>,
111    /// If `Some(name)`, this rule fires only when `group.definition == name`.
112    ///
113    /// Accepts any `Into<Arc<str>>` at construction time, so both `&'static str`
114    /// literals and owned `String`s are valid group scopes.
115    pub(super) group_scope: Option<Arc<str>>,
116    /// The rule closure.
117    #[allow(clippy::type_complexity)]
118    pub(super) rule: Arc<
119        dyn Fn(
120                &SegmentGroupIndexed<'_>,
121                &[Segment<'_>],
122                &ValidationRuleContext<'_>,
123                &mut Vec<ValidationIssue>,
124            ) + Send
125            + Sync,
126    >,
127}
128
129impl Clone for NamedGroupRule {
130    fn clone(&self) -> Self {
131        Self {
132            id: self.id.clone(),
133            group_scope: self.group_scope.clone(),
134            rule: Arc::clone(&self.rule),
135        }
136    }
137}
138
139/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
140pub struct ProfileRulePack {
141    name: String,
142    /// Set of EDIFACT message types this pack is scoped to (e.g. `"ORDERS"`, `"INVOIC"`).
143    ///
144    /// Most packs target one or two message types, so a `SmallVec<[String; 2]>` avoids
145    /// any heap allocation for the common case.
146    message_types: smallvec::SmallVec<[String; 2]>,
147    /// Association-assigned code (DE 0057) this pack is bound to, e.g. `"5.5.3a"`.
148    release: Option<String>,
149    pub(super) rules: Vec<NamedRule>,
150    pub(super) group_rules: Vec<NamedGroupRule>,
151    pub(super) bail_on_first_error: bool,
152    /// Maximum number of issues that a single rule may contribute per evaluation.
153    ///
154    /// `None` means unlimited.  Useful for noisy rules that can fire once per
155    /// segment occurrence in a large message (e.g. a missing-qualifier check
156    /// over thousands of DTM segments).
157    pub(super) max_issues_per_rule: Option<usize>,
158}
159
160impl ProfileRulePack {
161    /// Create an empty rule pack.
162    pub fn new(name: impl Into<String>) -> Self {
163        Self {
164            name: name.into(),
165            message_types: smallvec::SmallVec::new(),
166            release: None,
167            rules: Vec::new(),
168            group_rules: Vec::new(),
169            bail_on_first_error: false,
170            max_issues_per_rule: None,
171        }
172    }
173
174    /// Return the pack name.
175    pub fn name(&self) -> &str {
176        &self.name
177    }
178
179    /// Return the message types this pack is scoped to.
180    pub fn message_types(&self) -> impl Iterator<Item = &str> {
181        self.message_types.iter().map(|s| s.as_str())
182    }
183
184    /// Return the number of rules in this pack.
185    pub fn rule_count(&self) -> usize {
186        self.rules.len()
187    }
188
189    /// Return the number of named rules (those with a stable identifier).
190    pub fn named_rule_count(&self) -> usize {
191        self.rules.iter().filter(|r| r.id.is_some()).count()
192    }
193
194    /// Return the number of anonymous rules (those without a stable identifier).
195    pub fn anonymous_rule_count(&self) -> usize {
196        self.rules.iter().filter(|r| r.id.is_none()).count()
197    }
198
199    /// Iterate over the stable identifiers of all **named** rules in this pack.
200    pub fn rule_ids(&self) -> impl Iterator<Item = &str> {
201        self.rules.iter().filter_map(|r| r.id.as_deref())
202    }
203
204    /// Return the association-assigned release code this pack is bound to, if any.
205    pub fn release(&self) -> Option<&str> {
206        self.release.as_deref()
207    }
208
209    /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
210    pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
211        let s = message_type.into();
212        if !self.message_types.iter().any(|x| x == &s) {
213            self.message_types.push(s);
214        }
215        self
216    }
217
218    /// Bind this pack to a specific association-assigned code (DE 0057).
219    pub fn for_release(mut self, release: impl Into<String>) -> Self {
220        self.release = Some(release.into());
221        self
222    }
223
224    /// Stop evaluating rules in this pack after the first `Error`- or `Critical`-severity
225    /// finding.
226    pub fn with_bail_on_first_error(mut self, bail: bool) -> Self {
227        self.bail_on_first_error = bail;
228        self
229    }
230
231    /// Cap the number of issues any single rule may emit per evaluation pass.
232    ///
233    /// Excess issues are discarded, keeping one noisy rule from flooding the
234    /// report.
235    ///
236    /// The cap is *per rule per call* — not globally, and **not per group**: a
237    /// group rule's budget is spent across the whole tree walk rather than reset
238    /// at each occurrence, and a rule that has spent it is not called again on
239    /// that pass.
240    ///
241    /// `None` removes the cap.
242    ///
243    /// # Example
244    ///
245    /// ```
246    /// use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity, from_bytes};
247    ///
248    /// let pack = ProfileRulePack::new("NOISY")
249    ///     .with_max_issues_per_rule(2)
250    ///     .with_rule_fn(|segments, issues| {
251    ///         for segment in segments {
252    ///             issues.push(ValidationIssue::new(
253    ///                 ValidationSeverity::Warning,
254    ///                 format!("saw {}", segment.tag()),
255    ///             ));
256    ///         }
257    ///     });
258    ///
259    /// let segments: Vec<_> = from_bytes(b"BGM+1'DTM+2'RFF+3'NAD+4'")
260    ///     .collect::<Result<Vec<_>, _>>()?;
261    /// let report = edifact_rs::ValidationContext::builder()
262    ///     .with_profile_pack(pack)
263    ///     .build()
264    ///     .validate(&segments);
265    ///
266    /// assert_eq!(report.total_issues(), 2); // four segments, capped at two
267    /// # Ok::<(), edifact_rs::EdifactError>(())
268    /// ```
269    pub fn with_max_issues_per_rule(mut self, limit: impl Into<Option<usize>>) -> Self {
270        self.max_issues_per_rule = limit.into();
271        self
272    }
273
274    /// Add a rule closure.
275    ///
276    /// The closure receives the segments and a `Vec` to push issues into.  This
277    /// is the everyday form; reach for
278    /// [`with_contextual_rule_fn`][Self::with_contextual_rule_fn] only when the
279    /// rule needs the per-call [`ValidationRuleContext`].
280    ///
281    /// The rule is anonymous, so [`merge_with_override`][Self::merge_with_override]
282    /// cannot replace it — use [`with_named_rule_fn`][Self::with_named_rule_fn]
283    /// for anything a downstream pack may need to override.
284    ///
285    /// # Example
286    ///
287    /// ```
288    /// use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};
289    ///
290    /// let pack = ProfileRulePack::new("ORDERS").with_rule_fn(|segments, issues| {
291    ///     if !segments.iter().any(|segment| segment.tag == "BGM") {
292    ///         issues.push(ValidationIssue::new(
293    ///             ValidationSeverity::Error,
294    ///             "ORDERS requires a BGM",
295    ///         ));
296    ///     }
297    /// });
298    /// assert_eq!(pack.rule_count(), 1);
299    /// ```
300    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
301    where
302        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
303    {
304        self.rules.push(NamedRule {
305            id: None,
306            rule: Arc::new(StatelessClosureProfileRule(rule)),
307        });
308        self
309    }
310
311    /// Add a rule closure with a stable identifier.
312    ///
313    /// The identifier is what [`merge_with_override`][Self::merge_with_override]
314    /// matches on, so name every rule a downstream pack may need to replace.
315    pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
316    where
317        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
318    {
319        self.rules.push(NamedRule {
320            id: Some(id.into()),
321            rule: Arc::new(StatelessClosureProfileRule(rule)),
322        });
323        self
324    }
325
326    /// Add a rule closure that also receives the [`ValidationRuleContext`].
327    ///
328    /// The context carries the message reference, the message type, and any
329    /// typed metadata passed to
330    /// [`validate_with`][super::context::ValidationContext::validate_with].
331    /// When the rule does not need it, [`with_rule_fn`][Self::with_rule_fn] is
332    /// the shorter form.
333    pub fn with_contextual_rule_fn<F>(mut self, rule: F) -> Self
334    where
335        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
336            + Send
337            + Sync
338            + 'static,
339    {
340        self.rules.push(NamedRule {
341            id: None,
342            rule: Arc::new(ClosureProfileRule(rule)),
343        });
344        self
345    }
346
347    /// Add a context-aware rule closure with a stable identifier.
348    ///
349    /// [`with_contextual_rule_fn`][Self::with_contextual_rule_fn] plus the
350    /// override identifier described on
351    /// [`with_named_rule_fn`][Self::with_named_rule_fn].
352    pub fn with_named_contextual_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
353    where
354        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
355            + Send
356            + Sync
357            + 'static,
358    {
359        self.rules.push(NamedRule {
360            id: Some(id.into()),
361            rule: Arc::new(ClosureProfileRule(rule)),
362        });
363        self
364    }
365
366    /// Add a rule that asserts segment `tag` is present at least once.
367    ///
368    /// Emits an `Error`-severity issue when no segment with `tag` is found.
369    ///
370    /// # Example
371    ///
372    /// ```rust,ignore
373    /// let pack = ProfileRulePack::new("MY-PROFILE")
374    ///     .require_segment("BGM", "MY-BGM-M")
375    ///     .require_segment("DTM", "MY-DTM-M");
376    /// ```
377    pub fn require_segment(self, tag: &'static str, rule_id: impl Into<Arc<str>>) -> Self {
378        let id: Arc<str> = rule_id.into();
379        self.with_named_rule_fn(id.clone(), move |segments, issues| {
380            if !segments.iter().any(|s| s.tag == tag) {
381                issues.push(
382                    ValidationIssue::new(
383                        ValidationSeverity::Error,
384                        format!("mandatory segment {tag} is missing"),
385                    )
386                    .with_segment(tag)
387                    .with_rule_id(id.as_ref()),
388                );
389            }
390        })
391    }
392
393    /// Add a rule that asserts segment `tag` does **not** appear.
394    ///
395    /// Emits an `Error`-severity issue for each occurrence found.
396    pub fn forbid_segment(self, tag: &'static str, rule_id: impl Into<Arc<str>>) -> Self {
397        let id: Arc<str> = rule_id.into();
398        self.with_named_rule_fn(id.clone(), move |segments, issues| {
399            for (occ, s) in segments.iter().filter(|s| s.tag == tag).enumerate() {
400                issues.push(
401                    ValidationIssue::new(
402                        ValidationSeverity::Error,
403                        format!("segment {tag} must not appear"),
404                    )
405                    .with_span(s.span)
406                    .with_segment(tag)
407                    .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
408                    .with_rule_id(id.as_ref()),
409                );
410            }
411        })
412    }
413
414    /// Add a rule that asserts data element `de_qualifier` at `(element, component)` equals
415    /// `qualifier` for every occurrence of `tag`.
416    pub fn require_qualifier(
417        self,
418        tag: &'static str,
419        element: u8,
420        component: u8,
421        qualifier: &'static str,
422        rule_id: impl Into<Arc<str>>,
423    ) -> Self {
424        let id: Arc<str> = rule_id.into();
425        self.with_named_rule_fn(id.clone(), move |segments, issues| {
426            for (occ, s) in segments.iter().filter(|s| s.tag == tag).enumerate() {
427                let actual = s
428                    .get_element(element as usize)
429                    .and_then(|e| e.get_component(component as usize));
430                if actual != Some(qualifier) {
431                    issues.push(
432                        ValidationIssue::new(
433                            ValidationSeverity::Error,
434                            format!(
435                                "segment {tag} element {element} component {component} must be \
436                                 {qualifier:?} but found {:?}",
437                                actual.unwrap_or("<absent>")
438                            ),
439                        )
440                        .with_segment(tag)
441                        .with_element_index(element)
442                        .with_component_index(component)
443                        .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
444                        .with_rule_id(id.as_ref()),
445                    );
446                }
447            }
448        })
449    }
450
451    // ── Group-scoped rule builders ──────────────────────────────────────────
452
453    /// Add a group-aware rule closure that fires for **every** group node in the
454    /// DFS traversal of the segment-group tree.
455    ///
456    /// The closure receives:
457    /// - `group: &SegmentGroupIndexed` — the current tree node (with `definition`,
458    ///   `total_span`, `children`).
459    /// - `group_segments: &[Segment<'_>]` — all segments in this group's subtree
460    ///   (`all_segments[group.total_span.clone()]`).
461    /// - `context: &ValidationRuleContext<'_>` — per-call metadata and message info.
462    /// - `issues: &mut Vec<ValidationIssue>` — push violations here.
463    ///
464    /// # Group-name scoping
465    ///
466    /// Use [`with_scoped_group_rule_fn`](Self::with_scoped_group_rule_fn) when you
467    /// only want the rule to fire for a specific group definition (e.g. `"SG5"`).
468    pub fn with_group_rule_fn<F>(mut self, rule: F) -> Self
469    where
470        F: Fn(
471                &SegmentGroupIndexed<'_>,
472                &[Segment<'_>],
473                &ValidationRuleContext<'_>,
474                &mut Vec<ValidationIssue>,
475            ) + Send
476            + Sync
477            + 'static,
478    {
479        self.group_rules.push(NamedGroupRule {
480            id: None,
481            group_scope: None,
482            rule: Arc::new(rule),
483        });
484        self
485    }
486
487    /// Add a **named** group-aware rule closure that fires for every group node.
488    pub fn with_named_group_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
489    where
490        F: Fn(
491                &SegmentGroupIndexed<'_>,
492                &[Segment<'_>],
493                &ValidationRuleContext<'_>,
494                &mut Vec<ValidationIssue>,
495            ) + Send
496            + Sync
497            + 'static,
498    {
499        self.group_rules.push(NamedGroupRule {
500            id: Some(id.into()),
501            group_scope: None,
502            rule: Arc::new(rule),
503        });
504        self
505    }
506
507    /// Add a named group-aware rule closure scoped to a specific group definition.
508    ///
509    /// The closure is called only when the DFS traversal enters a group whose
510    /// [`SegmentGroupIndexed::definition`] equals `group_scope` (e.g. `"SG5"`).
511    ///
512    /// Accepts any `impl Into<Arc<str>>` as `group_scope`, so both `&'static str`
513    /// literals and owned `String`s are valid.
514    ///
515    /// # Example
516    ///
517    /// ```rust,ignore
518    /// let pack = ProfileRulePack::new("PROFILE-ORDERS")
519    ///     .with_scoped_group_rule_fn("SG5", "SG5-CAV-M", |_group, segs, _ctx, issues| {
520    ///         if !segs.iter().any(|s| s.tag == "CAV") {
521    ///             issues.push(
522    ///                 ValidationIssue::new(ValidationSeverity::Error, "CAV missing in SG5")
523    ///                     .with_segment("CAV")
524    ///                     .with_rule_id("SG5-CAV-M"),
525    ///             );
526    ///         }
527    ///     });
528    /// ```
529    pub fn with_scoped_group_rule_fn<F>(
530        mut self,
531        group_scope: impl Into<Arc<str>>,
532        id: impl Into<Arc<str>>,
533        rule: F,
534    ) -> Self
535    where
536        F: Fn(
537                &SegmentGroupIndexed<'_>,
538                &[Segment<'_>],
539                &ValidationRuleContext<'_>,
540                &mut Vec<ValidationIssue>,
541            ) + Send
542            + Sync
543            + 'static,
544    {
545        self.group_rules.push(NamedGroupRule {
546            id: Some(id.into()),
547            group_scope: Some(group_scope.into()),
548            rule: Arc::new(rule),
549        });
550        self
551    }
552
553    /// Assert segment `tag` is present in every occurrence of group `group_scope`.
554    ///
555    /// For example, `require_segment_in_group("SG5", "LOC", "SG5-LOC-M")` fires
556    /// once per `SG5` instance that contains no `LOC` segment.
557    ///
558    /// Issues are automatically annotated with the group name.
559    ///
560    /// Accepts any `impl Into<Arc<str>>` as `group_scope`.
561    pub fn require_segment_in_group(
562        self,
563        group_scope: impl Into<Arc<str>>,
564        tag: &'static str,
565        rule_id: impl Into<Arc<str>>,
566    ) -> Self {
567        let scope: Arc<str> = group_scope.into();
568        let id: Arc<str> = rule_id.into();
569        let scope_msg = Arc::clone(&scope);
570        self.with_scoped_group_rule_fn(scope, id.clone(), move |_group, segs, _ctx, issues| {
571            if !segs.iter().any(|s| s.tag == tag) {
572                issues.push(
573                    ValidationIssue::new(
574                        ValidationSeverity::Error,
575                        format!("mandatory segment {tag} is missing from group {scope_msg}"),
576                    )
577                    .with_segment(tag)
578                    .with_rule_id(id.as_ref()),
579                );
580            }
581        })
582    }
583
584    /// Assert segment `tag` does **not** appear in any occurrence of group `group_scope`.
585    ///
586    /// Emits an `Error`-severity issue for each occurrence found.
587    ///
588    /// Accepts any `impl Into<Arc<str>>` as `group_scope`.
589    pub fn forbid_segment_in_group(
590        self,
591        group_scope: impl Into<Arc<str>>,
592        tag: &'static str,
593        rule_id: impl Into<Arc<str>>,
594    ) -> Self {
595        let scope: Arc<str> = group_scope.into();
596        let id: Arc<str> = rule_id.into();
597        let scope_msg = Arc::clone(&scope);
598        self.with_scoped_group_rule_fn(scope, id.clone(), move |_group, segs, _ctx, issues| {
599            for (occ, s) in segs.iter().filter(|s| s.tag == tag).enumerate() {
600                issues.push(
601                    ValidationIssue::new(
602                        ValidationSeverity::Error,
603                        format!("segment {tag} must not appear in group {scope_msg}"),
604                    )
605                    .with_span(s.span)
606                    .with_segment(tag)
607                    .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
608                    .with_rule_id(id.as_ref()),
609                );
610            }
611        })
612    }
613
614    /// Assert qualifier `qualifier` at `(element, component)` in segment `tag` is
615    /// present in every occurrence of group `group_scope`.
616    ///
617    /// Accepts any `impl Into<Arc<str>>` as `group_scope`.
618    pub fn require_qualifier_in_group(
619        self,
620        group_scope: impl Into<Arc<str>>,
621        tag: &'static str,
622        element: u8,
623        component: u8,
624        qualifier: &'static str,
625        rule_id: impl Into<Arc<str>>,
626    ) -> Self {
627        let scope: Arc<str> = group_scope.into();
628        let id: Arc<str> = rule_id.into();
629        let scope_msg = Arc::clone(&scope);
630        self.with_scoped_group_rule_fn(scope, id.clone(), move |_group, segs, _ctx, issues| {
631            for (occ, s) in segs.iter().filter(|s| s.tag == tag).enumerate() {
632                let actual = s
633                    .get_element(element as usize)
634                    .and_then(|e| e.get_component(component as usize));
635                if actual != Some(qualifier) {
636                    issues.push(
637                        ValidationIssue::new(
638                            ValidationSeverity::Error,
639                            format!(
640                                "segment {tag} element {element} component {component} must be \
641                                 {qualifier:?} in group {scope_msg}, found {:?}",
642                                actual.unwrap_or("<absent>")
643                            ),
644                        )
645                        .with_segment(tag)
646                        .with_element_index(element)
647                        .with_component_index(component)
648                        .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
649                        .with_rule_id(id.as_ref()),
650                    );
651                }
652            }
653        })
654    }
655
656    /// Return the number of group-scoped rules in this pack.
657    pub fn group_rule_count(&self) -> usize {
658        self.group_rules.len()
659    }
660
661    // ── Scope filtering ────────────────────────────────────────────────────
662
663    /// Whether this pack's message-type and release scopes admit `segments`.
664    ///
665    /// Both scopes read `UNH` S009 — the message type is component 0, the
666    /// association assigned code (DE 0057) is component 4 — so the flat and the
667    /// group pass share one implementation instead of two copies that could
668    /// drift.  The `UNH` is looked up only for the scopes that are actually
669    /// configured, and the message type is taken from the pre-extracted
670    /// [`ValidationRuleContext`] when the surrounding
671    /// [`ValidationContext`][super::context::ValidationContext] already found it.
672    fn scope_admits(&self, segments: &[Segment<'_>], context: &ValidationRuleContext<'_>) -> bool {
673        if !self.message_types.is_empty() {
674            let message_type = context
675                .message_type
676                .or_else(|| unh_s009(segments).and_then(|e| e.get_component(0)));
677            if !message_type.is_some_and(|mt| self.message_types.iter().any(|x| x == mt)) {
678                return false;
679            }
680        }
681        if let Some(bound_release) = &self.release {
682            if unh_s009(segments).and_then(|e| e.get_component(4)) != Some(bound_release.as_str()) {
683                return false;
684            }
685        }
686        true
687    }
688
689    // ── Private group validation engine ────────────────────────────────────
690
691    /// Recursively walk the segment-group tree and evaluate group-scoped rules.
692    ///
693    /// Called internally by [`Validator::validate_group_batch`].
694    ///
695    /// `budget[i]` is how many further issues group rule `i` may still emit on
696    /// this call.  It is threaded through the recursion rather than reset per
697    /// group: [`with_max_issues_per_rule`][Self::with_max_issues_per_rule] caps
698    /// *per rule per call*, and a group rule fires once per group occurrence.
699    fn walk_group_tree(
700        &self,
701        group: &SegmentGroupIndexed<'_>,
702        all_segments: &[Segment<'_>],
703        report: &mut ValidationReport,
704        context: &ValidationRuleContext<'_>,
705        budget: &mut [usize],
706    ) {
707        let group_segs = all_segments.get(group.total_span.clone()).unwrap_or(&[]);
708        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
709
710        for (index, named) in self.group_rules.iter().enumerate() {
711            // Skip if this rule is scoped to a different group name.
712            if let Some(scope) = &named.group_scope {
713                if group.definition != scope.as_ref() {
714                    continue;
715                }
716            }
717            // Skip the call rather than discard its output: a rule that has
718            // spent its budget should not cost time it cannot report.
719            if self.max_issues_per_rule.is_some() && budget[index] == 0 {
720                continue;
721            }
722            let errors_before = report.errors.len();
723            (named.rule)(group, group_segs, context, &mut rule_issues);
724            if self.max_issues_per_rule.is_some() {
725                rule_issues.truncate(budget[index]);
726                budget[index] -= rule_issues.len();
727            }
728            for mut issue in rule_issues.drain(..) {
729                // Auto-stamp the group name if the rule didn't set it explicitly.
730                if issue.segment_group.is_none() {
731                    issue = issue.with_segment_group(group.definition);
732                }
733                match issue.severity {
734                    ValidationSeverity::Critical | ValidationSeverity::Error => {
735                        report.add_error(issue);
736                    }
737                    ValidationSeverity::Warning => {
738                        report.add_warning(issue);
739                    }
740                    ValidationSeverity::Info => {
741                        report.add_info(issue);
742                    }
743                }
744            }
745            if self.bail_on_first_error && report.errors.len() > errors_before {
746                return;
747            }
748        }
749
750        for child in &group.children {
751            let errors_before_child = report.errors.len();
752            self.walk_group_tree(child, all_segments, report, context, budget);
753            if self.bail_on_first_error && report.errors.len() > errors_before_child {
754                return;
755            }
756        }
757    }
758
759    /// Add a rule that implements [`ProfileRule`].
760    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
761        self.rules.push(NamedRule {
762            id: None,
763            rule: Arc::new(rule),
764        });
765        self
766    }
767
768    /// Add a named rule that implements [`ProfileRule`].
769    pub fn with_named_rule(
770        mut self,
771        id: impl Into<Arc<str>>,
772        rule: impl ProfileRule + 'static,
773    ) -> Self {
774        self.rules.push(NamedRule {
775            id: Some(id.into()),
776            rule: Arc::new(rule),
777        });
778        self
779    }
780
781    /// Prepend all rules from `base` to this pack.
782    ///
783    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
784    /// Message-type restrictions from `base` are also merged.  The resulting
785    /// release scope must be compatible with both packs.
786    ///
787    /// # Errors
788    ///
789    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
790    /// different release scopes.
791    ///
792    /// # Example
793    ///
794    /// ```rust,ignore
795    /// let base = ProfileRulePack::new("MIG-BASE")
796    ///     .with_rule_fn(/* mandatory segment rules */);
797    ///
798    /// let profile_4711 = ProfileRulePack::new("PROFILE-4711")
799    ///     .extend_from(&base)?
800    ///     .with_rule_fn(/* 4711-specific rules */);
801    /// ```
802    ///
803    /// When your base pack is wrapped in an [`Arc`] you can dereference it:
804    ///
805    /// ```rust,ignore
806    /// use std::sync::Arc;
807    ///
808    /// let base: Arc<ProfileRulePack> = Arc::new(
809    ///     ProfileRulePack::new("BASE").with_rule_fn(/* … */),
810    /// );
811    ///
812    /// let derived = ProfileRulePack::new("DERIVED")
813    ///     .extend_from(&*base)?          // deref Arc<T> to &T
814    ///     .with_rule_fn(/* … */);
815    /// ```
816    pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
817        let mut combined = base.rules.clone();
818        combined.append(&mut self.rules);
819        self.rules = combined;
820        // Prepend group rules from base too.
821        let mut combined_group = base.group_rules.clone();
822        combined_group.append(&mut self.group_rules);
823        self.group_rules = combined_group;
824        for mt in &base.message_types {
825            if !self.message_types.iter().any(|x| x == mt) {
826                self.message_types.push(mt.clone());
827            }
828        }
829        self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
830        Ok(self)
831    }
832
833    /// Merge `other` into `self`, with `other` taking precedence for any rule
834    /// whose id already exists in `self`.
835    ///
836    /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
837    ///   the rule at the same position in `self`.
838    /// - Rules in `other` with no id, or with an id not present in `self`, are
839    ///   **appended** to `self`.
840    /// - Rules present only in `self` (no matching override in `other`) are
841    ///   **retained unchanged**.
842    ///
843    /// # Errors
844    ///
845    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
846    /// different release scopes.
847    ///
848    /// # Example
849    ///
850    /// ```rust,ignore
851    /// let base = ProfileRulePack::new("MIG-5.4")
852    ///     .with_named_rule_fn("PROFILE-4711-BGM-M", |segs, _issues| { /* old */ });
853    ///
854    /// let delta = ProfileRulePack::new("MIG-5.5-delta")
855    ///     .with_named_rule_fn("PROFILE-4711-BGM-M", |segs, _issues| { /* updated */ });
856    ///
857    /// // `result` runs the updated BGM-M rule only once:
858    /// let result = base.merge_with_override(delta)?;
859    /// assert_eq!(result.rule_count(), 1);
860    /// ```
861    pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
862        let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
863        for (idx, rule) in self.rules.iter().enumerate() {
864            if let Some(id) = &rule.id {
865                id_to_index.insert(id.clone(), idx);
866            }
867        }
868
869        let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
870        let mut to_append = Vec::new();
871
872        for other_rule in other.rules.drain(..) {
873            if let Some(id) = &other_rule.id {
874                if let Some(&idx) = id_to_index.get(id) {
875                    replacements.push((idx, other_rule));
876                } else {
877                    to_append.push(other_rule);
878                }
879            } else {
880                to_append.push(other_rule);
881            }
882        }
883
884        for (idx, rule) in replacements {
885            if idx < self.rules.len() {
886                self.rules[idx] = rule;
887            }
888        }
889
890        self.rules.append(&mut to_append);
891        for mt in other.message_types.drain(..) {
892            if !self.message_types.contains(&mt) {
893                self.message_types.push(mt);
894            }
895        }
896        // Merge group rules: named overrides replace matching entries; others are appended.
897        let mut group_id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
898        for (idx, rule) in self.group_rules.iter().enumerate() {
899            if let Some(id) = &rule.id {
900                group_id_to_index.insert(id.clone(), idx);
901            }
902        }
903        let mut group_replacements: Vec<(usize, NamedGroupRule)> = Vec::new();
904        let mut group_to_append = Vec::new();
905        for other_rule in other.group_rules.drain(..) {
906            if let Some(id) = &other_rule.id {
907                if let Some(&idx) = group_id_to_index.get(id) {
908                    group_replacements.push((idx, other_rule));
909                } else {
910                    group_to_append.push(other_rule);
911                }
912            } else {
913                group_to_append.push(other_rule);
914            }
915        }
916        for (idx, rule) in group_replacements {
917            if idx < self.group_rules.len() {
918                self.group_rules[idx] = rule;
919            }
920        }
921        self.group_rules.append(&mut group_to_append);
922        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
923        Ok(self)
924    }
925}
926
927/// The `UNH` S009 composite (message identifier), if the slice carries a `UNH`.
928///
929/// One lookup answers both scope questions: message type is component 0,
930/// association assigned code (DE 0057) is component 4.
931fn unh_s009<'s, 'd>(segments: &'s [Segment<'d>]) -> Option<&'s crate::model::Element<'d>> {
932    segments
933        .iter()
934        .find(|s| s.tag == "UNH")
935        .and_then(|s| s.get_element(1))
936}
937
938pub(super) fn merge_release_scopes(
939    current: Option<String>,
940    incoming: Option<String>,
941) -> Result<Option<String>, EdifactError> {
942    match (current, incoming) {
943        (Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
944            current: x,
945            incoming: y,
946        }),
947        (Some(x), Some(_)) => Ok(Some(x)),
948        (Some(x), None) => Ok(Some(x)),
949        (None, incoming) => Ok(incoming),
950    }
951}
952
953impl Validator for ProfileRulePack {
954    fn validate_batch(
955        &self,
956        segments: &[Segment<'_>],
957        report: &mut ValidationReport,
958        context: &ValidationRuleContext<'_>,
959    ) {
960        if !self.scope_admits(segments, context) {
961            return;
962        }
963
964        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
965
966        for named in &self.rules {
967            let errors_before = report.errors.len();
968            named.rule.evaluate(segments, context, &mut rule_issues);
969            // Apply per-rule issue cap if configured.
970            if let Some(limit) = self.max_issues_per_rule {
971                rule_issues.truncate(limit);
972            }
973            for issue in rule_issues.drain(..) {
974                match issue.severity {
975                    ValidationSeverity::Critical | ValidationSeverity::Error => {
976                        report.add_error(issue);
977                    }
978                    ValidationSeverity::Warning => {
979                        report.add_warning(issue);
980                    }
981                    ValidationSeverity::Info => {
982                        report.add_info(issue);
983                    }
984                }
985            }
986            if self.bail_on_first_error && report.errors.len() > errors_before {
987                return;
988            }
989        }
990    }
991
992    fn validate_group_batch(
993        &self,
994        root: &SegmentGroupIndexed<'_>,
995        all_segments: &[Segment<'_>],
996        report: &mut ValidationReport,
997        context: &ValidationRuleContext<'_>,
998    ) {
999        if self.group_rules.is_empty() {
1000            return;
1001        }
1002
1003        if !self.scope_admits(all_segments, context) {
1004            return;
1005        }
1006
1007        // One budget slot per group rule, spent across the whole tree walk.
1008        let mut budget =
1009            vec![self.max_issues_per_rule.unwrap_or(usize::MAX); self.group_rules.len()];
1010        self.walk_group_tree(root, all_segments, report, context, &mut budget);
1011    }
1012
1013    fn has_group_rules(&self) -> bool {
1014        !self.group_rules.is_empty()
1015    }
1016
1017    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
1018        Some(Box::new(self.clone()))
1019    }
1020}
1021
1022impl Clone for ProfileRulePack {
1023    fn clone(&self) -> Self {
1024        Self {
1025            name: self.name.clone(),
1026            message_types: self.message_types.clone(),
1027            release: self.release.clone(),
1028            rules: self.rules.clone(),
1029            group_rules: self.group_rules.clone(),
1030            bail_on_first_error: self.bail_on_first_error,
1031            max_issues_per_rule: self.max_issues_per_rule,
1032        }
1033    }
1034}
1035
1036impl std::fmt::Debug for ProfileRulePack {
1037    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1038        f.debug_struct("ProfileRulePack")
1039            .field("name", &self.name)
1040            .field("message_types", &self.message_types)
1041            .field("release", &self.release)
1042            .field("rule_count", &self.rules.len())
1043            .field("group_rule_count", &self.group_rules.len())
1044            .field("bail_on_first_error", &self.bail_on_first_error)
1045            .finish()
1046    }
1047}
1048
1049/// `Arc<ProfileRulePack>` can be plugged directly into a [`super::context::ValidationContext`].
1050///
1051/// Forking (for `fork_with_message_ref`) only increments the reference count — no
1052/// deep copy of the rule vec is performed.  This is the zero-allocation path for
1053/// downstream code that caches packs in a `LazyLock` or `OnceLock`.
1054///
1055/// # Example
1056///
1057/// ```rust,ignore
1058/// use std::sync::{Arc, LazyLock};
1059/// use edifact_rs::{ProfileRulePack, ValidationContext};
1060///
1061/// static ORDERS_PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
1062///     Arc::new(
1063///         ProfileRulePack::new("ORDERS-MIG")
1064///             .for_message_type("ORDERS")
1065///             .require_segment("BGM", "MIG-BGM-M"),
1066///     )
1067/// });
1068///
1069/// let ctx = ValidationContext::builder()
1070///     .with_profile_pack_arc(Arc::clone(&ORDERS_PACK))
1071///     .build();
1072/// ```
1073impl Validator for Arc<ProfileRulePack> {
1074    fn validate_batch(
1075        &self,
1076        segments: &[Segment<'_>],
1077        report: &mut ValidationReport,
1078        context: &ValidationRuleContext<'_>,
1079    ) {
1080        self.as_ref().validate_batch(segments, report, context);
1081    }
1082
1083    fn validate_group_batch(
1084        &self,
1085        root: &SegmentGroupIndexed<'_>,
1086        all_segments: &[Segment<'_>],
1087        report: &mut ValidationReport,
1088        context: &ValidationRuleContext<'_>,
1089    ) {
1090        self.as_ref()
1091            .validate_group_batch(root, all_segments, report, context);
1092    }
1093
1094    fn has_group_rules(&self) -> bool {
1095        self.as_ref().has_group_rules()
1096    }
1097
1098    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
1099        Some(Box::new(Arc::clone(self)))
1100    }
1101}