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