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_lenient_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_lenient_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    /// When a rule fires more than `limit` times in one `validate_batch` call,
234    /// the excess issues are silently discarded.  This prevents a single noisy
235    /// rule (e.g. a missing-qualifier check iterating thousands of segments)
236    /// from flooding the report.
237    ///
238    /// The cap applies *per rule per call*, not globally.  Pass `None` to
239    /// remove a previously set cap and restore unlimited output.
240    pub fn with_max_issues_per_rule(mut self, limit: impl Into<Option<usize>>) -> Self {
241        self.max_issues_per_rule = limit.into();
242        self
243    }
244
245    /// Add a context-aware rule closure.
246    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
247    where
248        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
249            + Send
250            + Sync
251            + 'static,
252    {
253        self.rules.push(NamedRule {
254            id: None,
255            rule: Arc::new(ClosureProfileRule(rule)),
256        });
257        self
258    }
259
260    /// Add a context-aware rule closure with a stable identifier.
261    pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
262    where
263        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
264            + Send
265            + Sync
266            + 'static,
267    {
268        self.rules.push(NamedRule {
269            id: Some(id.into()),
270            rule: Arc::new(ClosureProfileRule(rule)),
271        });
272        self
273    }
274
275    /// Add a context-free rule closure.
276    pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
277    where
278        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
279    {
280        self.rules.push(NamedRule {
281            id: None,
282            rule: Arc::new(StatelessClosureProfileRule(rule)),
283        });
284        self
285    }
286
287    /// Add a context-free rule closure with a stable identifier.
288    pub fn with_named_stateless_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
289    where
290        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
291    {
292        self.rules.push(NamedRule {
293            id: Some(id.into()),
294            rule: Arc::new(StatelessClosureProfileRule(rule)),
295        });
296        self
297    }
298
299    /// Add a rule that asserts segment `tag` is present at least once.
300    ///
301    /// Emits an `Error`-severity issue when no segment with `tag` is found.
302    ///
303    /// # Example
304    ///
305    /// ```rust,ignore
306    /// let pack = ProfileRulePack::new("MY-PROFILE")
307    ///     .require_segment("BGM", "MY-BGM-M")
308    ///     .require_segment("DTM", "MY-DTM-M");
309    /// ```
310    pub fn require_segment(self, tag: &'static str, rule_id: impl Into<Arc<str>>) -> Self {
311        let id: Arc<str> = rule_id.into();
312        self.with_named_stateless_rule_fn(id.clone(), move |segments, issues| {
313            if !segments.iter().any(|s| s.tag == tag) {
314                issues.push(
315                    ValidationIssue::new(
316                        ValidationSeverity::Error,
317                        format!("mandatory segment {tag} is missing"),
318                    )
319                    .with_segment(tag)
320                    .with_rule_id(id.as_ref()),
321                );
322            }
323        })
324    }
325
326    /// Add a rule that asserts segment `tag` does **not** appear.
327    ///
328    /// Emits an `Error`-severity issue for each occurrence found.
329    pub fn forbid_segment(self, tag: &'static str, rule_id: impl Into<Arc<str>>) -> Self {
330        let id: Arc<str> = rule_id.into();
331        self.with_named_stateless_rule_fn(id.clone(), move |segments, issues| {
332            for (occ, s) in segments.iter().filter(|s| s.tag == tag).enumerate() {
333                issues.push(
334                    ValidationIssue::new(
335                        ValidationSeverity::Error,
336                        format!("segment {tag} must not appear"),
337                    )
338                    .with_span(s.span)
339                    .with_segment(tag)
340                    .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
341                    .with_rule_id(id.as_ref()),
342                );
343            }
344        })
345    }
346
347    /// Add a rule that asserts data element `de_qualifier` at `(element, component)` equals
348    /// `qualifier` for every occurrence of `tag`.
349    pub fn require_qualifier(
350        self,
351        tag: &'static str,
352        element: u8,
353        component: u8,
354        qualifier: &'static str,
355        rule_id: impl Into<Arc<str>>,
356    ) -> Self {
357        let id: Arc<str> = rule_id.into();
358        self.with_named_stateless_rule_fn(id.clone(), move |segments, issues| {
359            for (occ, s) in segments.iter().filter(|s| s.tag == tag).enumerate() {
360                let actual = s
361                    .get_element(element as usize)
362                    .and_then(|e| e.get_component(component as usize));
363                if actual != Some(qualifier) {
364                    issues.push(
365                        ValidationIssue::new(
366                            ValidationSeverity::Error,
367                            format!(
368                                "segment {tag} element {element} component {component} must be \
369                                 {qualifier:?} but found {:?}",
370                                actual.unwrap_or("<absent>")
371                            ),
372                        )
373                        .with_segment(tag)
374                        .with_element_index(element)
375                        .with_component_index(component)
376                        .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
377                        .with_rule_id(id.as_ref()),
378                    );
379                }
380            }
381        })
382    }
383
384    // ── Group-scoped rule builders ──────────────────────────────────────────
385
386    /// Add a group-aware rule closure that fires for **every** group node in the
387    /// DFS traversal of the segment-group tree.
388    ///
389    /// The closure receives:
390    /// - `group: &SegmentGroupIndexed` — the current tree node (with `definition`,
391    ///   `total_span`, `children`).
392    /// - `group_segments: &[Segment<'_>]` — all segments in this group's subtree
393    ///   (`all_segments[group.total_span.clone()]`).
394    /// - `context: &ValidationRuleContext<'_>` — per-call metadata and message info.
395    /// - `issues: &mut Vec<ValidationIssue>` — push violations here.
396    ///
397    /// # Group-name scoping
398    ///
399    /// Use [`with_scoped_group_rule_fn`](Self::with_scoped_group_rule_fn) when you
400    /// only want the rule to fire for a specific group definition (e.g. `"SG5"`).
401    pub fn with_group_rule_fn<F>(mut self, rule: F) -> Self
402    where
403        F: Fn(
404                &SegmentGroupIndexed<'_>,
405                &[Segment<'_>],
406                &ValidationRuleContext<'_>,
407                &mut Vec<ValidationIssue>,
408            ) + Send
409            + Sync
410            + 'static,
411    {
412        self.group_rules.push(NamedGroupRule {
413            id: None,
414            group_scope: None,
415            rule: Arc::new(rule),
416        });
417        self
418    }
419
420    /// Add a **named** group-aware rule closure that fires for every group node.
421    pub fn with_named_group_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
422    where
423        F: Fn(
424                &SegmentGroupIndexed<'_>,
425                &[Segment<'_>],
426                &ValidationRuleContext<'_>,
427                &mut Vec<ValidationIssue>,
428            ) + Send
429            + Sync
430            + 'static,
431    {
432        self.group_rules.push(NamedGroupRule {
433            id: Some(id.into()),
434            group_scope: None,
435            rule: Arc::new(rule),
436        });
437        self
438    }
439
440    /// Add a named group-aware rule closure scoped to a specific group definition.
441    ///
442    /// The closure is called only when the DFS traversal enters a group whose
443    /// [`SegmentGroupIndexed::definition`] equals `group_scope` (e.g. `"SG5"`).
444    ///
445    /// Accepts any `impl Into<Arc<str>>` as `group_scope`, so both `&'static str`
446    /// literals and owned `String`s are valid.
447    ///
448    /// # Example
449    ///
450    /// ```rust,ignore
451    /// let pack = ProfileRulePack::new("PROFILE-ORDERS")
452    ///     .with_scoped_group_rule_fn("SG5", "SG5-CAV-M", |_group, segs, _ctx, issues| {
453    ///         if !segs.iter().any(|s| s.tag == "CAV") {
454    ///             issues.push(
455    ///                 ValidationIssue::new(ValidationSeverity::Error, "CAV missing in SG5")
456    ///                     .with_segment("CAV")
457    ///                     .with_rule_id("SG5-CAV-M"),
458    ///             );
459    ///         }
460    ///     });
461    /// ```
462    pub fn with_scoped_group_rule_fn<F>(
463        mut self,
464        group_scope: impl Into<Arc<str>>,
465        id: impl Into<Arc<str>>,
466        rule: F,
467    ) -> Self
468    where
469        F: Fn(
470                &SegmentGroupIndexed<'_>,
471                &[Segment<'_>],
472                &ValidationRuleContext<'_>,
473                &mut Vec<ValidationIssue>,
474            ) + Send
475            + Sync
476            + 'static,
477    {
478        self.group_rules.push(NamedGroupRule {
479            id: Some(id.into()),
480            group_scope: Some(group_scope.into()),
481            rule: Arc::new(rule),
482        });
483        self
484    }
485
486    /// Assert segment `tag` is present in every occurrence of group `group_scope`.
487    ///
488    /// For example, `require_segment_in_group("SG5", "LOC", "SG5-LOC-M")` fires
489    /// once per `SG5` instance that contains no `LOC` segment.
490    ///
491    /// Issues are automatically annotated with the group name.
492    ///
493    /// Accepts any `impl Into<Arc<str>>` as `group_scope`.
494    pub fn require_segment_in_group(
495        self,
496        group_scope: impl Into<Arc<str>>,
497        tag: &'static str,
498        rule_id: impl Into<Arc<str>>,
499    ) -> Self {
500        let scope: Arc<str> = group_scope.into();
501        let id: Arc<str> = rule_id.into();
502        let scope_msg = Arc::clone(&scope);
503        self.with_scoped_group_rule_fn(scope, id.clone(), move |_group, segs, _ctx, issues| {
504            if !segs.iter().any(|s| s.tag == tag) {
505                issues.push(
506                    ValidationIssue::new(
507                        ValidationSeverity::Error,
508                        format!("mandatory segment {tag} is missing from group {scope_msg}"),
509                    )
510                    .with_segment(tag)
511                    .with_rule_id(id.as_ref()),
512                );
513            }
514        })
515    }
516
517    /// Assert segment `tag` does **not** appear in any occurrence of group `group_scope`.
518    ///
519    /// Emits an `Error`-severity issue for each occurrence found.
520    ///
521    /// Accepts any `impl Into<Arc<str>>` as `group_scope`.
522    pub fn forbid_segment_in_group(
523        self,
524        group_scope: impl Into<Arc<str>>,
525        tag: &'static str,
526        rule_id: impl Into<Arc<str>>,
527    ) -> Self {
528        let scope: Arc<str> = group_scope.into();
529        let id: Arc<str> = rule_id.into();
530        let scope_msg = Arc::clone(&scope);
531        self.with_scoped_group_rule_fn(scope, id.clone(), move |_group, segs, _ctx, issues| {
532            for (occ, s) in segs.iter().filter(|s| s.tag == tag).enumerate() {
533                issues.push(
534                    ValidationIssue::new(
535                        ValidationSeverity::Error,
536                        format!("segment {tag} must not appear in group {scope_msg}"),
537                    )
538                    .with_span(s.span)
539                    .with_segment(tag)
540                    .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
541                    .with_rule_id(id.as_ref()),
542                );
543            }
544        })
545    }
546
547    /// Assert qualifier `qualifier` at `(element, component)` in segment `tag` is
548    /// present in every occurrence of group `group_scope`.
549    ///
550    /// Accepts any `impl Into<Arc<str>>` as `group_scope`.
551    pub fn require_qualifier_in_group(
552        self,
553        group_scope: impl Into<Arc<str>>,
554        tag: &'static str,
555        element: u8,
556        component: u8,
557        qualifier: &'static str,
558        rule_id: impl Into<Arc<str>>,
559    ) -> Self {
560        let scope: Arc<str> = group_scope.into();
561        let id: Arc<str> = rule_id.into();
562        let scope_msg = Arc::clone(&scope);
563        self.with_scoped_group_rule_fn(scope, id.clone(), move |_group, segs, _ctx, issues| {
564            for (occ, s) in segs.iter().filter(|s| s.tag == tag).enumerate() {
565                let actual = s
566                    .get_element(element as usize)
567                    .and_then(|e| e.get_component(component as usize));
568                if actual != Some(qualifier) {
569                    issues.push(
570                        ValidationIssue::new(
571                            ValidationSeverity::Error,
572                            format!(
573                                "segment {tag} element {element} component {component} must be \
574                                 {qualifier:?} in group {scope_msg}, found {:?}",
575                                actual.unwrap_or("<absent>")
576                            ),
577                        )
578                        .with_segment(tag)
579                        .with_element_index(element)
580                        .with_component_index(component)
581                        .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
582                        .with_rule_id(id.as_ref()),
583                    );
584                }
585            }
586        })
587    }
588
589    /// Return the number of group-scoped rules in this pack.
590    pub fn group_rule_count(&self) -> usize {
591        self.group_rules.len()
592    }
593
594    // ── Scope filtering ────────────────────────────────────────────────────
595
596    /// Whether this pack's message-type and release scopes admit `segments`.
597    ///
598    /// Both scopes read `UNH` S009 — the message type is component 0, the
599    /// association assigned code (DE 0057) is component 4 — so the flat and the
600    /// group pass share one implementation instead of two copies that could
601    /// drift.  The `UNH` is looked up only for the scopes that are actually
602    /// configured, and the message type is taken from the pre-extracted
603    /// [`ValidationRuleContext`] when the surrounding
604    /// [`ValidationContext`][super::context::ValidationContext] already found it.
605    fn scope_admits(&self, segments: &[Segment<'_>], context: &ValidationRuleContext<'_>) -> bool {
606        if !self.message_types.is_empty() {
607            let message_type = context
608                .message_type
609                .or_else(|| unh_s009(segments).and_then(|e| e.get_component(0)));
610            if !message_type.is_some_and(|mt| self.message_types.iter().any(|x| x == mt)) {
611                return false;
612            }
613        }
614        if let Some(bound_release) = &self.release {
615            if unh_s009(segments).and_then(|e| e.get_component(4)) != Some(bound_release.as_str()) {
616                return false;
617            }
618        }
619        true
620    }
621
622    // ── Private group validation engine ────────────────────────────────────
623
624    /// Recursively walk the segment-group tree and evaluate group-scoped rules.
625    ///
626    /// Called internally by [`Validator::validate_group_batch`].
627    fn walk_group_tree(
628        &self,
629        group: &SegmentGroupIndexed<'_>,
630        all_segments: &[Segment<'_>],
631        report: &mut ValidationReport,
632        context: &ValidationRuleContext<'_>,
633    ) {
634        let group_segs = all_segments.get(group.total_span.clone()).unwrap_or(&[]);
635        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
636
637        for named in &self.group_rules {
638            // Skip if this rule is scoped to a different group name.
639            if let Some(scope) = &named.group_scope {
640                if group.definition != scope.as_ref() {
641                    continue;
642                }
643            }
644            let errors_before = report.errors.len();
645            (named.rule)(group, group_segs, context, &mut rule_issues);
646            // Apply the same per-rule cap as the flat path.  Group rules fire
647            // once per group occurrence, so they are the most likely to flood a
648            // report — exactly what `max_issues_per_rule` exists to prevent.
649            if let Some(limit) = self.max_issues_per_rule {
650                rule_issues.truncate(limit);
651            }
652            for mut issue in rule_issues.drain(..) {
653                // Auto-stamp the group name if the rule didn't set it explicitly.
654                if issue.segment_group.is_none() {
655                    issue = issue.with_segment_group(group.definition);
656                }
657                match issue.severity {
658                    ValidationSeverity::Critical | ValidationSeverity::Error => {
659                        report.add_error(issue);
660                    }
661                    ValidationSeverity::Warning => {
662                        report.add_warning(issue);
663                    }
664                    ValidationSeverity::Info => {
665                        report.add_info(issue);
666                    }
667                }
668            }
669            if self.bail_on_first_error && report.errors.len() > errors_before {
670                return;
671            }
672        }
673
674        for child in &group.children {
675            let errors_before_child = report.errors.len();
676            self.walk_group_tree(child, all_segments, report, context);
677            if self.bail_on_first_error && report.errors.len() > errors_before_child {
678                return;
679            }
680        }
681    }
682
683    /// Add a rule that implements [`ProfileRule`].
684    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
685        self.rules.push(NamedRule {
686            id: None,
687            rule: Arc::new(rule),
688        });
689        self
690    }
691
692    /// Add a named rule that implements [`ProfileRule`].
693    pub fn with_named_rule(
694        mut self,
695        id: impl Into<Arc<str>>,
696        rule: impl ProfileRule + 'static,
697    ) -> Self {
698        self.rules.push(NamedRule {
699            id: Some(id.into()),
700            rule: Arc::new(rule),
701        });
702        self
703    }
704
705    /// Prepend all rules from `base` to this pack.
706    ///
707    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
708    /// Message-type restrictions from `base` are also merged.  The resulting
709    /// release scope must be compatible with both packs.
710    ///
711    /// # Errors
712    ///
713    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
714    /// different release scopes.
715    ///
716    /// # Example
717    ///
718    /// ```rust,ignore
719    /// let base = ProfileRulePack::new("MIG-BASE")
720    ///     .with_stateless_rule_fn(/* mandatory segment rules */);
721    ///
722    /// let profile_4711 = ProfileRulePack::new("PROFILE-4711")
723    ///     .extend_from(&base)?
724    ///     .with_stateless_rule_fn(/* 4711-specific rules */);
725    /// ```
726    ///
727    /// When your base pack is wrapped in an [`Arc`] you can dereference it:
728    ///
729    /// ```rust,ignore
730    /// use std::sync::Arc;
731    ///
732    /// let base: Arc<ProfileRulePack> = Arc::new(
733    ///     ProfileRulePack::new("BASE").with_stateless_rule_fn(/* … */),
734    /// );
735    ///
736    /// let derived = ProfileRulePack::new("DERIVED")
737    ///     .extend_from(&*base)?          // deref Arc<T> to &T
738    ///     .with_stateless_rule_fn(/* … */);
739    /// ```
740    pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
741        let mut combined = base.rules.clone();
742        combined.append(&mut self.rules);
743        self.rules = combined;
744        // Prepend group rules from base too.
745        let mut combined_group = base.group_rules.clone();
746        combined_group.append(&mut self.group_rules);
747        self.group_rules = combined_group;
748        for mt in &base.message_types {
749            if !self.message_types.iter().any(|x| x == mt) {
750                self.message_types.push(mt.clone());
751            }
752        }
753        self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
754        Ok(self)
755    }
756
757    /// Merge `other` into `self`, with `other` taking precedence for any rule
758    /// whose id already exists in `self`.
759    ///
760    /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
761    ///   the rule at the same position in `self`.
762    /// - Rules in `other` with no id, or with an id not present in `self`, are
763    ///   **appended** to `self`.
764    /// - Rules present only in `self` (no matching override in `other`) are
765    ///   **retained unchanged**.
766    ///
767    /// # Errors
768    ///
769    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
770    /// different release scopes.
771    ///
772    /// # Example
773    ///
774    /// ```rust,ignore
775    /// let base = ProfileRulePack::new("MIG-5.4")
776    ///     .with_named_stateless_rule_fn("PROFILE-4711-BGM-M", |segs, _issues| { /* old */ });
777    ///
778    /// let delta = ProfileRulePack::new("MIG-5.5-delta")
779    ///     .with_named_stateless_rule_fn("PROFILE-4711-BGM-M", |segs, _issues| { /* updated */ });
780    ///
781    /// // `result` runs the updated BGM-M rule only once:
782    /// let result = base.merge_with_override(delta)?;
783    /// assert_eq!(result.rule_count(), 1);
784    /// ```
785    pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
786        let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
787        for (idx, rule) in self.rules.iter().enumerate() {
788            if let Some(id) = &rule.id {
789                id_to_index.insert(id.clone(), idx);
790            }
791        }
792
793        let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
794        let mut to_append = Vec::new();
795
796        for other_rule in other.rules.drain(..) {
797            if let Some(id) = &other_rule.id {
798                if let Some(&idx) = id_to_index.get(id) {
799                    replacements.push((idx, other_rule));
800                } else {
801                    to_append.push(other_rule);
802                }
803            } else {
804                to_append.push(other_rule);
805            }
806        }
807
808        for (idx, rule) in replacements {
809            if idx < self.rules.len() {
810                self.rules[idx] = rule;
811            }
812        }
813
814        self.rules.append(&mut to_append);
815        for mt in other.message_types.drain(..) {
816            if !self.message_types.contains(&mt) {
817                self.message_types.push(mt);
818            }
819        }
820        // Merge group rules: named overrides replace matching entries; others are appended.
821        let mut group_id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
822        for (idx, rule) in self.group_rules.iter().enumerate() {
823            if let Some(id) = &rule.id {
824                group_id_to_index.insert(id.clone(), idx);
825            }
826        }
827        let mut group_replacements: Vec<(usize, NamedGroupRule)> = Vec::new();
828        let mut group_to_append = Vec::new();
829        for other_rule in other.group_rules.drain(..) {
830            if let Some(id) = &other_rule.id {
831                if let Some(&idx) = group_id_to_index.get(id) {
832                    group_replacements.push((idx, other_rule));
833                } else {
834                    group_to_append.push(other_rule);
835                }
836            } else {
837                group_to_append.push(other_rule);
838            }
839        }
840        for (idx, rule) in group_replacements {
841            if idx < self.group_rules.len() {
842                self.group_rules[idx] = rule;
843            }
844        }
845        self.group_rules.append(&mut group_to_append);
846        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
847        Ok(self)
848    }
849}
850
851/// The `UNH` S009 composite (message identifier), if the slice carries a `UNH`.
852///
853/// One lookup answers both scope questions: message type is component 0,
854/// association assigned code (DE 0057) is component 4.
855fn unh_s009<'s, 'd>(segments: &'s [Segment<'d>]) -> Option<&'s crate::model::Element<'d>> {
856    segments
857        .iter()
858        .find(|s| s.tag == "UNH")
859        .and_then(|s| s.get_element(1))
860}
861
862pub(super) fn merge_release_scopes(
863    current: Option<String>,
864    incoming: Option<String>,
865) -> Result<Option<String>, EdifactError> {
866    match (current, incoming) {
867        (Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
868            current: x,
869            incoming: y,
870        }),
871        (Some(x), Some(_)) => Ok(Some(x)),
872        (Some(x), None) => Ok(Some(x)),
873        (None, incoming) => Ok(incoming),
874    }
875}
876
877impl Validator for ProfileRulePack {
878    fn validate_batch(
879        &self,
880        segments: &[Segment<'_>],
881        report: &mut ValidationReport,
882        context: &ValidationRuleContext<'_>,
883    ) {
884        if !self.scope_admits(segments, context) {
885            return;
886        }
887
888        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
889
890        for named in &self.rules {
891            let errors_before = report.errors.len();
892            named.rule.evaluate(segments, context, &mut rule_issues);
893            // Apply per-rule issue cap if configured.
894            if let Some(limit) = self.max_issues_per_rule {
895                rule_issues.truncate(limit);
896            }
897            for issue in rule_issues.drain(..) {
898                match issue.severity {
899                    ValidationSeverity::Critical | ValidationSeverity::Error => {
900                        report.add_error(issue);
901                    }
902                    ValidationSeverity::Warning => {
903                        report.add_warning(issue);
904                    }
905                    ValidationSeverity::Info => {
906                        report.add_info(issue);
907                    }
908                }
909            }
910            if self.bail_on_first_error && report.errors.len() > errors_before {
911                return;
912            }
913        }
914    }
915
916    fn validate_group_batch(
917        &self,
918        root: &SegmentGroupIndexed<'_>,
919        all_segments: &[Segment<'_>],
920        report: &mut ValidationReport,
921        context: &ValidationRuleContext<'_>,
922    ) {
923        if self.group_rules.is_empty() {
924            return;
925        }
926
927        if !self.scope_admits(all_segments, context) {
928            return;
929        }
930
931        self.walk_group_tree(root, all_segments, report, context);
932    }
933
934    fn has_group_rules(&self) -> bool {
935        !self.group_rules.is_empty()
936    }
937
938    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
939        Some(Box::new(self.clone()))
940    }
941}
942
943impl Clone for ProfileRulePack {
944    fn clone(&self) -> Self {
945        Self {
946            name: self.name.clone(),
947            message_types: self.message_types.clone(),
948            release: self.release.clone(),
949            rules: self.rules.clone(),
950            group_rules: self.group_rules.clone(),
951            bail_on_first_error: self.bail_on_first_error,
952            max_issues_per_rule: self.max_issues_per_rule,
953        }
954    }
955}
956
957impl std::fmt::Debug for ProfileRulePack {
958    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
959        f.debug_struct("ProfileRulePack")
960            .field("name", &self.name)
961            .field("message_types", &self.message_types)
962            .field("release", &self.release)
963            .field("rule_count", &self.rules.len())
964            .field("group_rule_count", &self.group_rules.len())
965            .field("bail_on_first_error", &self.bail_on_first_error)
966            .finish()
967    }
968}
969
970/// `Arc<ProfileRulePack>` can be plugged directly into a [`super::context::ValidationContext`].
971///
972/// Forking (for `fork_with_message_ref`) only increments the reference count — no
973/// deep copy of the rule vec is performed.  This is the zero-allocation path for
974/// downstream code that caches packs in a `LazyLock` or `OnceLock`.
975///
976/// # Example
977///
978/// ```rust,ignore
979/// use std::sync::{Arc, LazyLock};
980/// use edifact_rs::{ProfileRulePack, ValidationContext};
981///
982/// static ORDERS_PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
983///     Arc::new(
984///         ProfileRulePack::new("ORDERS-MIG")
985///             .for_message_type("ORDERS")
986///             .require_segment("BGM", "MIG-BGM-M"),
987///     )
988/// });
989///
990/// let ctx = ValidationContext::builder()
991///     .with_profile_pack_arc(Arc::clone(&ORDERS_PACK))
992///     .build();
993/// ```
994impl Validator for Arc<ProfileRulePack> {
995    fn validate_batch(
996        &self,
997        segments: &[Segment<'_>],
998        report: &mut ValidationReport,
999        context: &ValidationRuleContext<'_>,
1000    ) {
1001        self.as_ref().validate_batch(segments, report, context);
1002    }
1003
1004    fn validate_group_batch(
1005        &self,
1006        root: &SegmentGroupIndexed<'_>,
1007        all_segments: &[Segment<'_>],
1008        report: &mut ValidationReport,
1009        context: &ValidationRuleContext<'_>,
1010    ) {
1011        self.as_ref()
1012            .validate_group_batch(root, all_segments, report, context);
1013    }
1014
1015    fn has_group_rules(&self) -> bool {
1016        self.as_ref().has_group_rules()
1017    }
1018
1019    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
1020        Some(Box::new(Arc::clone(self)))
1021    }
1022}