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. `"AHB-11001-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-AHB")
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_offset(s.span.start)
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("AHB-MSCONS")
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_offset(s.span.start)
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    // ── Private group validation engine ────────────────────────────────────
595
596    /// Recursively walk the segment-group tree and evaluate group-scoped rules.
597    ///
598    /// Called internally by [`Validator::validate_group_batch`].
599    fn walk_group_tree(
600        &self,
601        group: &SegmentGroupIndexed,
602        all_segments: &[Segment<'_>],
603        report: &mut ValidationReport,
604        context: &ValidationRuleContext<'_>,
605    ) {
606        let group_segs = all_segments.get(group.total_span.clone()).unwrap_or(&[]);
607        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
608
609        for named in &self.group_rules {
610            // Skip if this rule is scoped to a different group name.
611            if let Some(scope) = &named.group_scope {
612                if group.definition != scope.as_ref() {
613                    continue;
614                }
615            }
616            let errors_before = report.errors.len();
617            (named.rule)(group, group_segs, context, &mut rule_issues);
618            for mut issue in rule_issues.drain(..) {
619                // Auto-stamp the group name if the rule didn't set it explicitly.
620                if issue.segment_group.is_none() {
621                    issue = issue.with_segment_group(group.definition);
622                }
623                match issue.severity {
624                    ValidationSeverity::Critical | ValidationSeverity::Error => {
625                        report.add_error(issue);
626                    }
627                    ValidationSeverity::Warning => {
628                        report.add_warning(issue);
629                    }
630                    ValidationSeverity::Info => {
631                        report.add_info(issue);
632                    }
633                }
634            }
635            if self.bail_on_first_error && report.errors.len() > errors_before {
636                return;
637            }
638        }
639
640        for child in &group.children {
641            let errors_before_child = report.errors.len();
642            self.walk_group_tree(child, all_segments, report, context);
643            if self.bail_on_first_error && report.errors.len() > errors_before_child {
644                return;
645            }
646        }
647    }
648
649    /// Add a rule that implements [`ProfileRule`].
650    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
651        self.rules.push(NamedRule {
652            id: None,
653            rule: Arc::new(rule),
654        });
655        self
656    }
657
658    /// Add a named rule that implements [`ProfileRule`].
659    pub fn with_named_rule(
660        mut self,
661        id: impl Into<Arc<str>>,
662        rule: impl ProfileRule + 'static,
663    ) -> Self {
664        self.rules.push(NamedRule {
665            id: Some(id.into()),
666            rule: Arc::new(rule),
667        });
668        self
669    }
670
671    /// Prepend all rules from `base` to this pack.
672    ///
673    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
674    /// Message-type restrictions from `base` are also merged.  The resulting
675    /// release scope must be compatible with both packs.
676    ///
677    /// # Errors
678    ///
679    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
680    /// different release scopes.
681    ///
682    /// # Example
683    ///
684    /// ```rust,ignore
685    /// let base = ProfileRulePack::new("MIG-UTILMD-BASE")
686    ///     .with_stateless_rule_fn(/* mandatory segment rules */);
687    ///
688    /// let ahb_11001 = ProfileRulePack::new("AHB-11001")
689    ///     .extend_from(&base)?
690    ///     .with_stateless_rule_fn(/* 11001-specific rules */);
691    /// ```
692    ///
693    /// When your base pack is wrapped in an [`Arc`] you can dereference it:
694    ///
695    /// ```rust,ignore
696    /// use std::sync::Arc;
697    ///
698    /// let base: Arc<ProfileRulePack> = Arc::new(
699    ///     ProfileRulePack::new("BASE").with_stateless_rule_fn(/* … */),
700    /// );
701    ///
702    /// let derived = ProfileRulePack::new("DERIVED")
703    ///     .extend_from(&*base)?          // deref Arc<T> to &T
704    ///     .with_stateless_rule_fn(/* … */);
705    /// ```
706    pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
707        let mut combined = base.rules.clone();
708        combined.append(&mut self.rules);
709        self.rules = combined;
710        // Prepend group rules from base too.
711        let mut combined_group = base.group_rules.clone();
712        combined_group.append(&mut self.group_rules);
713        self.group_rules = combined_group;
714        for mt in &base.message_types {
715            if !self.message_types.iter().any(|x| x == mt) {
716                self.message_types.push(mt.clone());
717            }
718        }
719        self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
720        Ok(self)
721    }
722
723    /// Merge `other` into `self`, with `other` taking precedence for any rule
724    /// whose id already exists in `self`.
725    ///
726    /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
727    ///   the rule at the same position in `self`.
728    /// - Rules in `other` with no id, or with an id not present in `self`, are
729    ///   **appended** to `self`.
730    /// - Rules present only in `self` (no matching override in `other`) are
731    ///   **retained unchanged**.
732    ///
733    /// # Errors
734    ///
735    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
736    /// different release scopes.
737    ///
738    /// # Example
739    ///
740    /// ```rust,ignore
741    /// let base = ProfileRulePack::new("UTILMD-5.4")
742    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* old */ });
743    ///
744    /// let delta = ProfileRulePack::new("UTILMD-5.5-delta")
745    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* updated */ });
746    ///
747    /// // `result` runs the updated BGM-M rule only once:
748    /// let result = base.merge_with_override(delta)?;
749    /// assert_eq!(result.rule_count(), 1);
750    /// ```
751    pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
752        let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
753        for (idx, rule) in self.rules.iter().enumerate() {
754            if let Some(id) = &rule.id {
755                id_to_index.insert(id.clone(), idx);
756            }
757        }
758
759        let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
760        let mut to_append = Vec::new();
761
762        for other_rule in other.rules.drain(..) {
763            if let Some(id) = &other_rule.id {
764                if let Some(&idx) = id_to_index.get(id) {
765                    replacements.push((idx, other_rule));
766                } else {
767                    to_append.push(other_rule);
768                }
769            } else {
770                to_append.push(other_rule);
771            }
772        }
773
774        for (idx, rule) in replacements {
775            if idx < self.rules.len() {
776                self.rules[idx] = rule;
777            }
778        }
779
780        self.rules.append(&mut to_append);
781        for mt in other.message_types.drain(..) {
782            if !self.message_types.iter().any(|x| *x == mt) {
783                self.message_types.push(mt);
784            }
785        }
786        // Merge group rules: named overrides replace matching entries; others are appended.
787        let mut group_id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
788        for (idx, rule) in self.group_rules.iter().enumerate() {
789            if let Some(id) = &rule.id {
790                group_id_to_index.insert(id.clone(), idx);
791            }
792        }
793        let mut group_replacements: Vec<(usize, NamedGroupRule)> = Vec::new();
794        let mut group_to_append = Vec::new();
795        for other_rule in other.group_rules.drain(..) {
796            if let Some(id) = &other_rule.id {
797                if let Some(&idx) = group_id_to_index.get(id) {
798                    group_replacements.push((idx, other_rule));
799                } else {
800                    group_to_append.push(other_rule);
801                }
802            } else {
803                group_to_append.push(other_rule);
804            }
805        }
806        for (idx, rule) in group_replacements {
807            if idx < self.group_rules.len() {
808                self.group_rules[idx] = rule;
809            }
810        }
811        self.group_rules.append(&mut group_to_append);
812        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
813        Ok(self)
814    }
815}
816
817pub(super) fn merge_release_scopes(
818    current: Option<String>,
819    incoming: Option<String>,
820) -> Result<Option<String>, EdifactError> {
821    match (current, incoming) {
822        (Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
823            current: x,
824            incoming: y,
825        }),
826        (Some(x), Some(_)) => Ok(Some(x)),
827        (Some(x), None) => Ok(Some(x)),
828        (None, incoming) => Ok(incoming),
829    }
830}
831
832impl Validator for ProfileRulePack {
833    fn validate_batch(
834        &self,
835        segments: &[Segment<'_>],
836        report: &mut ValidationReport,
837        context: &ValidationRuleContext<'_>,
838    ) {
839        // Use the pre-extracted message type from the rule context when available
840        // (set by ValidationContext to avoid per-pack O(n) UNH scans, F-017).
841        let unh_e1_storage;
842        let unh_e1: Option<&crate::model::Element<'_>> = if context.message_type.is_some() {
843            None
844        } else {
845            unh_e1_storage = segments
846                .iter()
847                .find(|s| s.tag == "UNH")
848                .and_then(|s| s.get_element(1));
849            unh_e1_storage
850        };
851
852        let message_type = context
853            .message_type
854            .or_else(|| unh_e1.and_then(|e| e.get_component(0)));
855
856        if !self.message_types.is_empty()
857            && !message_type.is_some_and(|mt| self.message_types.iter().any(|x| x.as_str() == mt))
858        {
859            return;
860        }
861
862        if let Some(bound_release) = &self.release {
863            let msg_association = segments
864                .iter()
865                .find(|s| s.tag == "UNH")
866                .and_then(|s| s.get_element(1))
867                .and_then(|e| e.get_component(4));
868            if msg_association != Some(bound_release.as_str()) {
869                return;
870            }
871        }
872
873        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
874
875        for named in &self.rules {
876            let errors_before = report.errors.len();
877            named.rule.evaluate(segments, context, &mut rule_issues);
878            // Apply per-rule issue cap if configured.
879            if let Some(limit) = self.max_issues_per_rule {
880                rule_issues.truncate(limit);
881            }
882            for issue in rule_issues.drain(..) {
883                match issue.severity {
884                    ValidationSeverity::Critical | ValidationSeverity::Error => {
885                        report.add_error(issue);
886                    }
887                    ValidationSeverity::Warning => {
888                        report.add_warning(issue);
889                    }
890                    ValidationSeverity::Info => {
891                        report.add_info(issue);
892                    }
893                }
894            }
895            if self.bail_on_first_error && report.errors.len() > errors_before {
896                return;
897            }
898        }
899    }
900
901    fn validate_group_batch(
902        &self,
903        root: &SegmentGroupIndexed,
904        all_segments: &[Segment<'_>],
905        report: &mut ValidationReport,
906        context: &ValidationRuleContext<'_>,
907    ) {
908        if self.group_rules.is_empty() {
909            return;
910        }
911
912        // Apply message-type and release scope filters (same as validate_batch).
913        let unh_e1_storage;
914        let unh_e1: Option<&crate::model::Element<'_>> = if context.message_type.is_some() {
915            None
916        } else {
917            unh_e1_storage = all_segments
918                .iter()
919                .find(|s| s.tag == "UNH")
920                .and_then(|s| s.get_element(1));
921            unh_e1_storage
922        };
923        let message_type = context
924            .message_type
925            .or_else(|| unh_e1.and_then(|e| e.get_component(0)));
926
927        if !self.message_types.is_empty()
928            && !message_type.is_some_and(|mt| self.message_types.iter().any(|x| x.as_str() == mt))
929        {
930            return;
931        }
932
933        if let Some(bound_release) = &self.release {
934            let msg_association = all_segments
935                .iter()
936                .find(|s| s.tag == "UNH")
937                .and_then(|s| s.get_element(1))
938                .and_then(|e| e.get_component(4));
939            if msg_association != Some(bound_release.as_str()) {
940                return;
941            }
942        }
943
944        self.walk_group_tree(root, all_segments, report, context);
945    }
946
947    fn has_group_rules(&self) -> bool {
948        !self.group_rules.is_empty()
949    }
950
951    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
952        Some(Box::new(self.clone()))
953    }
954}
955
956impl Clone for ProfileRulePack {
957    fn clone(&self) -> Self {
958        Self {
959            name: self.name.clone(),
960            message_types: self.message_types.clone(),
961            release: self.release.clone(),
962            rules: self.rules.clone(),
963            group_rules: self.group_rules.clone(),
964            bail_on_first_error: self.bail_on_first_error,
965            max_issues_per_rule: self.max_issues_per_rule,
966        }
967    }
968}
969
970impl std::fmt::Debug for ProfileRulePack {
971    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
972        f.debug_struct("ProfileRulePack")
973            .field("name", &self.name)
974            .field("message_types", &self.message_types)
975            .field("release", &self.release)
976            .field("rule_count", &self.rules.len())
977            .field("group_rule_count", &self.group_rules.len())
978            .field("bail_on_first_error", &self.bail_on_first_error)
979            .finish()
980    }
981}
982
983/// `Arc<ProfileRulePack>` can be plugged directly into a [`super::context::ValidationContext`].
984///
985/// Forking (for `fork_with_message_ref`) only increments the reference count — no
986/// deep copy of the rule vec is performed.  This is the zero-allocation path for
987/// downstream code that caches packs in a `LazyLock` or `OnceLock`.
988///
989/// # Example
990///
991/// ```rust,ignore
992/// use std::sync::{Arc, LazyLock};
993/// use edifact_rs::{ProfileRulePack, ValidationContext};
994///
995/// static ORDERS_PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
996///     Arc::new(
997///         ProfileRulePack::new("ORDERS-MIG")
998///             .for_message_type("ORDERS")
999///             .require_segment("BGM", "MIG-BGM-M"),
1000///     )
1001/// });
1002///
1003/// let ctx = ValidationContext::builder()
1004///     .with_profile_pack_arc(Arc::clone(&ORDERS_PACK))
1005///     .build();
1006/// ```
1007impl Validator for Arc<ProfileRulePack> {
1008    fn validate_batch(
1009        &self,
1010        segments: &[Segment<'_>],
1011        report: &mut ValidationReport,
1012        context: &ValidationRuleContext<'_>,
1013    ) {
1014        self.as_ref().validate_batch(segments, report, context);
1015    }
1016
1017    fn validate_group_batch(
1018        &self,
1019        root: &SegmentGroupIndexed,
1020        all_segments: &[Segment<'_>],
1021        report: &mut ValidationReport,
1022        context: &ValidationRuleContext<'_>,
1023    ) {
1024        self.as_ref()
1025            .validate_group_batch(root, all_segments, report, context);
1026    }
1027
1028    fn has_group_rules(&self) -> bool {
1029        self.as_ref().has_group_rules()
1030    }
1031
1032    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
1033        Some(Box::new(Arc::clone(self)))
1034    }
1035}