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/// # `bail_on_first_error` interaction
23///
24/// When [`ProfileRulePack::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    pub(super) group_scope: Option<&'static str>,
113    /// The rule closure.
114    pub(super) rule: Arc<
115        dyn Fn(
116                &SegmentGroupIndexed,
117                &[Segment<'_>],
118                &ValidationRuleContext<'_>,
119                &mut Vec<ValidationIssue>,
120            ) + Send
121            + Sync,
122    >,
123}
124
125impl Clone for NamedGroupRule {
126    fn clone(&self) -> Self {
127        Self {
128            id: self.id.clone(),
129            group_scope: self.group_scope,
130            rule: Arc::clone(&self.rule),
131        }
132    }
133}
134
135/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
136pub struct ProfileRulePack {
137    name: String,
138    /// Set of EDIFACT message types this pack is scoped to (e.g. `"ORDERS"`, `"INVOIC"`).
139    message_types: std::collections::BTreeSet<String>,
140    /// Association-assigned code (DE 0057) this pack is bound to, e.g. `"5.5.3a"`.
141    release: Option<String>,
142    pub(super) rules: Vec<NamedRule>,
143    pub(super) group_rules: Vec<NamedGroupRule>,
144    pub(super) bail_on_first_error: bool,
145}
146
147impl ProfileRulePack {
148    /// Create an empty rule pack.
149    pub fn new(name: impl Into<String>) -> Self {
150        Self {
151            name: name.into(),
152            message_types: std::collections::BTreeSet::new(),
153            release: None,
154            rules: Vec::new(),
155            group_rules: Vec::new(),
156            bail_on_first_error: false,
157        }
158    }
159
160    /// Return the pack name.
161    pub fn name(&self) -> &str {
162        &self.name
163    }
164
165    /// Return the message types this pack is scoped to.
166    pub fn message_types(&self) -> impl Iterator<Item = &str> {
167        self.message_types.iter().map(|s| s.as_str())
168    }
169
170    /// Return the number of rules in this pack.
171    pub fn rule_count(&self) -> usize {
172        self.rules.len()
173    }
174
175    /// Return the number of named rules (those with a stable identifier).
176    pub fn named_rule_count(&self) -> usize {
177        self.rules.iter().filter(|r| r.id.is_some()).count()
178    }
179
180    /// Return the number of anonymous rules (those without a stable identifier).
181    pub fn anonymous_rule_count(&self) -> usize {
182        self.rules.iter().filter(|r| r.id.is_none()).count()
183    }
184
185    /// Iterate over the stable identifiers of all **named** rules in this pack.
186    pub fn rule_ids(&self) -> impl Iterator<Item = &str> {
187        self.rules.iter().filter_map(|r| r.id.as_deref())
188    }
189
190    /// Return the association-assigned release code this pack is bound to, if any.
191    pub fn release(&self) -> Option<&str> {
192        self.release.as_deref()
193    }
194
195    /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
196    pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
197        self.message_types.insert(message_type.into());
198        self
199    }
200
201    /// Bind this pack to a specific association-assigned code (DE 0057).
202    pub fn for_release(mut self, release: impl Into<String>) -> Self {
203        self.release = Some(release.into());
204        self
205    }
206
207    /// Stop evaluating rules in this pack after the first `Error`- or `Critical`-severity
208    /// finding.
209    pub fn bail_on_first_error(mut self, bail: bool) -> Self {
210        self.bail_on_first_error = bail;
211        self
212    }
213
214    /// Add a context-aware rule closure.
215    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
216    where
217        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
218            + Send
219            + Sync
220            + 'static,
221    {
222        self.rules.push(NamedRule {
223            id: None,
224            rule: Arc::new(ClosureProfileRule(rule)),
225        });
226        self
227    }
228
229    /// Add a context-aware rule closure with a stable identifier.
230    pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
231    where
232        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
233            + Send
234            + Sync
235            + 'static,
236    {
237        self.rules.push(NamedRule {
238            id: Some(id.into()),
239            rule: Arc::new(ClosureProfileRule(rule)),
240        });
241        self
242    }
243
244    /// Add a context-free rule closure.
245    pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
246    where
247        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
248    {
249        self.rules.push(NamedRule {
250            id: None,
251            rule: Arc::new(StatelessClosureProfileRule(rule)),
252        });
253        self
254    }
255
256    /// Add a context-free rule closure with a stable identifier.
257    pub fn with_named_stateless_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
258    where
259        F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
260    {
261        self.rules.push(NamedRule {
262            id: Some(id.into()),
263            rule: Arc::new(StatelessClosureProfileRule(rule)),
264        });
265        self
266    }
267
268    /// Add a rule that asserts segment `tag` is present at least once.
269    ///
270    /// Emits an `Error`-severity issue when no segment with `tag` is found.
271    ///
272    /// # Example
273    ///
274    /// ```rust,ignore
275    /// let pack = ProfileRulePack::new("MY-AHB")
276    ///     .require_segment("BGM", "MY-BGM-M")
277    ///     .require_segment("DTM", "MY-DTM-M");
278    /// ```
279    pub fn require_segment(self, tag: &'static str, rule_id: impl Into<Arc<str>>) -> Self {
280        let id: Arc<str> = rule_id.into();
281        self.with_named_stateless_rule_fn(id.clone(), move |segments, issues| {
282            if !segments.iter().any(|s| s.tag == tag) {
283                issues.push(
284                    ValidationIssue::new(
285                        ValidationSeverity::Error,
286                        format!("mandatory segment {tag} is missing"),
287                    )
288                    .with_segment(tag)
289                    .with_rule_id(id.as_ref()),
290                );
291            }
292        })
293    }
294
295    /// Add a rule that asserts segment `tag` does **not** appear.
296    ///
297    /// Emits an `Error`-severity issue for each occurrence found.
298    pub fn forbid_segment(self, tag: &'static str, rule_id: impl Into<Arc<str>>) -> Self {
299        let id: Arc<str> = rule_id.into();
300        self.with_named_stateless_rule_fn(id.clone(), move |segments, issues| {
301            for (occ, _s) in segments.iter().filter(|s| s.tag == tag).enumerate() {
302                issues.push(
303                    ValidationIssue::new(
304                        ValidationSeverity::Error,
305                        format!("segment {tag} must not appear"),
306                    )
307                    .with_segment(tag)
308                    .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
309                    .with_rule_id(id.as_ref()),
310                );
311            }
312        })
313    }
314
315    /// Add a rule that asserts data element `de_qualifier` at `(element, component)` equals
316    /// `qualifier` for every occurrence of `tag`.
317    pub fn require_qualifier(
318        self,
319        tag: &'static str,
320        element: u8,
321        component: u8,
322        qualifier: &'static str,
323        rule_id: impl Into<Arc<str>>,
324    ) -> Self {
325        let id: Arc<str> = rule_id.into();
326        self.with_named_stateless_rule_fn(id.clone(), move |segments, issues| {
327            for (occ, s) in segments.iter().filter(|s| s.tag == tag).enumerate() {
328                let actual = s
329                    .get_element(element as usize)
330                    .and_then(|e| e.get_component(component as usize));
331                if actual != Some(qualifier) {
332                    issues.push(
333                        ValidationIssue::new(
334                            ValidationSeverity::Error,
335                            format!(
336                                "segment {tag} element {element} component {component} must be \
337                                 {qualifier:?} but found {:?}",
338                                actual.unwrap_or("<absent>")
339                            ),
340                        )
341                        .with_segment(tag)
342                        .with_element_index(element)
343                        .with_component_index(component)
344                        .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
345                        .with_rule_id(id.as_ref()),
346                    );
347                }
348            }
349        })
350    }
351
352    // ── Group-scoped rule builders ──────────────────────────────────────────
353
354    /// Add a group-aware rule closure that fires for **every** group node in the
355    /// DFS traversal of the segment-group tree.
356    ///
357    /// The closure receives:
358    /// - `group: &SegmentGroupIndexed` — the current tree node (with `definition`,
359    ///   `total_span`, `children`).
360    /// - `group_segments: &[Segment<'_>]` — all segments in this group's subtree
361    ///   (`all_segments[group.total_span.clone()]`).
362    /// - `context: &ValidationRuleContext<'_>` — per-call metadata and message info.
363    /// - `issues: &mut Vec<ValidationIssue>` — push violations here.
364    ///
365    /// # Group-name scoping
366    ///
367    /// Use [`with_scoped_group_rule_fn`](Self::with_scoped_group_rule_fn) when you
368    /// only want the rule to fire for a specific group definition (e.g. `"SG5"`).
369    pub fn with_group_rule_fn<F>(mut self, rule: F) -> Self
370    where
371        F: Fn(
372                &SegmentGroupIndexed,
373                &[Segment<'_>],
374                &ValidationRuleContext<'_>,
375                &mut Vec<ValidationIssue>,
376            ) + Send
377            + Sync
378            + 'static,
379    {
380        self.group_rules.push(NamedGroupRule {
381            id: None,
382            group_scope: None,
383            rule: Arc::new(rule),
384        });
385        self
386    }
387
388    /// Add a **named** group-aware rule closure that fires for every group node.
389    pub fn with_named_group_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
390    where
391        F: Fn(
392                &SegmentGroupIndexed,
393                &[Segment<'_>],
394                &ValidationRuleContext<'_>,
395                &mut Vec<ValidationIssue>,
396            ) + Send
397            + Sync
398            + 'static,
399    {
400        self.group_rules.push(NamedGroupRule {
401            id: Some(id.into()),
402            group_scope: None,
403            rule: Arc::new(rule),
404        });
405        self
406    }
407
408    /// Add a named group-aware rule closure scoped to a specific group definition.
409    ///
410    /// The closure is called only when the DFS traversal enters a group whose
411    /// [`SegmentGroupIndexed::definition`] equals `group_scope` (e.g. `"SG5"`).
412    ///
413    /// # Example
414    ///
415    /// ```rust,ignore
416    /// let pack = ProfileRulePack::new("AHB-MSCONS")
417    ///     .with_scoped_group_rule_fn("SG5", "SG5-CAV-M", |_group, segs, _ctx, issues| {
418    ///         if !segs.iter().any(|s| s.tag == "CAV") {
419    ///             issues.push(
420    ///                 ValidationIssue::new(ValidationSeverity::Error, "CAV missing in SG5")
421    ///                     .with_segment("CAV")
422    ///                     .with_rule_id("SG5-CAV-M"),
423    ///             );
424    ///         }
425    ///     });
426    /// ```
427    pub fn with_scoped_group_rule_fn<F>(
428        mut self,
429        group_scope: &'static str,
430        id: impl Into<Arc<str>>,
431        rule: F,
432    ) -> Self
433    where
434        F: Fn(
435                &SegmentGroupIndexed,
436                &[Segment<'_>],
437                &ValidationRuleContext<'_>,
438                &mut Vec<ValidationIssue>,
439            ) + Send
440            + Sync
441            + 'static,
442    {
443        self.group_rules.push(NamedGroupRule {
444            id: Some(id.into()),
445            group_scope: Some(group_scope),
446            rule: Arc::new(rule),
447        });
448        self
449    }
450
451    /// Assert segment `tag` is present in every occurrence of group `group_scope`.
452    ///
453    /// For example, `require_segment_in_group("SG5", "LOC", "SG5-LOC-M")` fires
454    /// once per `SG5` instance that contains no `LOC` segment.
455    ///
456    /// Issues are automatically annotated with the group name.
457    pub fn require_segment_in_group(
458        self,
459        group_scope: &'static str,
460        tag: &'static str,
461        rule_id: impl Into<Arc<str>>,
462    ) -> Self {
463        let id: Arc<str> = rule_id.into();
464        self.with_scoped_group_rule_fn(
465            group_scope,
466            id.clone(),
467            move |_group, segs, _ctx, issues| {
468                if !segs.iter().any(|s| s.tag == tag) {
469                    issues.push(
470                        ValidationIssue::new(
471                            ValidationSeverity::Error,
472                            format!("mandatory segment {tag} is missing from group {group_scope}"),
473                        )
474                        .with_segment(tag)
475                        .with_rule_id(id.as_ref()),
476                    );
477                }
478            },
479        )
480    }
481
482    /// Assert segment `tag` does **not** appear in any occurrence of group `group_scope`.
483    ///
484    /// Emits an `Error`-severity issue for each occurrence found.
485    pub fn forbid_segment_in_group(
486        self,
487        group_scope: &'static str,
488        tag: &'static str,
489        rule_id: impl Into<Arc<str>>,
490    ) -> Self {
491        let id: Arc<str> = rule_id.into();
492        self.with_scoped_group_rule_fn(
493            group_scope,
494            id.clone(),
495            move |_group, segs, _ctx, issues| {
496                for (occ, _s) in segs.iter().filter(|s| s.tag == tag).enumerate() {
497                    issues.push(
498                        ValidationIssue::new(
499                            ValidationSeverity::Error,
500                            format!("segment {tag} must not appear in group {group_scope}"),
501                        )
502                        .with_segment(tag)
503                        .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
504                        .with_rule_id(id.as_ref()),
505                    );
506                }
507            },
508        )
509    }
510
511    /// Assert qualifier `qualifier` at `(element, component)` in segment `tag` is
512    /// present in every occurrence of group `group_scope`.
513    pub fn require_qualifier_in_group(
514        self,
515        group_scope: &'static str,
516        tag: &'static str,
517        element: u8,
518        component: u8,
519        qualifier: &'static str,
520        rule_id: impl Into<Arc<str>>,
521    ) -> Self {
522        let id: Arc<str> = rule_id.into();
523        self.with_scoped_group_rule_fn(
524            group_scope,
525            id.clone(),
526            move |_group, segs, _ctx, issues| {
527                for (occ, s) in segs.iter().filter(|s| s.tag == tag).enumerate() {
528                    let actual = s
529                        .get_element(element as usize)
530                        .and_then(|e| e.get_component(component as usize));
531                    if actual != Some(qualifier) {
532                        issues.push(
533                            ValidationIssue::new(
534                                ValidationSeverity::Error,
535                                format!(
536                                    "segment {tag} element {element} component {component} must be \
537                                 {qualifier:?} in group {group_scope}, found {:?}",
538                                    actual.unwrap_or("<absent>")
539                                ),
540                            )
541                            .with_segment(tag)
542                            .with_element_index(element)
543                            .with_component_index(component)
544                            .with_segment_occurrence(u16::try_from(occ).unwrap_or(u16::MAX))
545                            .with_rule_id(id.as_ref()),
546                        );
547                    }
548                }
549            },
550        )
551    }
552
553    /// Return the number of group-scoped rules in this pack.
554    pub fn group_rule_count(&self) -> usize {
555        self.group_rules.len()
556    }
557
558    // ── Private group validation engine ────────────────────────────────────
559
560    /// Recursively walk the segment-group tree and evaluate group-scoped rules.
561    ///
562    /// Called internally by [`Validator::validate_group_batch`].
563    fn walk_group_tree(
564        &self,
565        group: &SegmentGroupIndexed,
566        all_segments: &[Segment<'_>],
567        report: &mut ValidationReport,
568        context: &ValidationRuleContext<'_>,
569    ) {
570        let group_segs = all_segments.get(group.total_span.clone()).unwrap_or(&[]);
571        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
572
573        for named in &self.group_rules {
574            // Skip if this rule is scoped to a different group name.
575            if let Some(scope) = named.group_scope {
576                if group.definition != scope {
577                    continue;
578                }
579            }
580            let errors_before = report.errors.len();
581            (named.rule)(group, group_segs, context, &mut rule_issues);
582            for mut issue in rule_issues.drain(..) {
583                // Auto-stamp the group name if the rule didn't set it explicitly.
584                if issue.segment_group.is_none() {
585                    issue = issue.with_segment_group(group.definition);
586                }
587                match issue.severity {
588                    ValidationSeverity::Critical | ValidationSeverity::Error => {
589                        report.add_error(issue);
590                    }
591                    ValidationSeverity::Warning => {
592                        report.add_warning(issue);
593                    }
594                    ValidationSeverity::Info => {
595                        report.add_info(issue);
596                    }
597                }
598            }
599            if self.bail_on_first_error && report.errors.len() > errors_before {
600                return;
601            }
602        }
603
604        for child in &group.children {
605            let errors_before_child = report.errors.len();
606            self.walk_group_tree(child, all_segments, report, context);
607            if self.bail_on_first_error && report.errors.len() > errors_before_child {
608                return;
609            }
610        }
611    }
612
613    /// Add a rule that implements [`ProfileRule`].
614    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
615        self.rules.push(NamedRule {
616            id: None,
617            rule: Arc::new(rule),
618        });
619        self
620    }
621
622    /// Add a named rule that implements [`ProfileRule`].
623    pub fn with_named_rule(
624        mut self,
625        id: impl Into<Arc<str>>,
626        rule: impl ProfileRule + 'static,
627    ) -> Self {
628        self.rules.push(NamedRule {
629            id: Some(id.into()),
630            rule: Arc::new(rule),
631        });
632        self
633    }
634
635    /// Prepend all rules from `base` to this pack.
636    ///
637    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
638    /// Message-type restrictions from `base` are also merged.  The resulting
639    /// release scope must be compatible with both packs.
640    ///
641    /// # Errors
642    ///
643    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
644    /// different release scopes.
645    ///
646    /// # Example
647    ///
648    /// ```rust,ignore
649    /// let base = ProfileRulePack::new("MIG-UTILMD-BASE")
650    ///     .with_stateless_rule_fn(/* mandatory segment rules */);
651    ///
652    /// let ahb_11001 = ProfileRulePack::new("AHB-11001")
653    ///     .extend_from(&base)?
654    ///     .with_stateless_rule_fn(/* 11001-specific rules */);
655    /// ```
656    pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
657        let mut combined = base.rules.clone();
658        combined.append(&mut self.rules);
659        self.rules = combined;
660        // Prepend group rules from base too.
661        let mut combined_group = base.group_rules.clone();
662        combined_group.append(&mut self.group_rules);
663        self.group_rules = combined_group;
664        for mt in &base.message_types {
665            self.message_types.insert(mt.clone());
666        }
667        self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
668        Ok(self)
669    }
670
671    /// Merge `other` into `self`, with `other` taking precedence for any rule
672    /// whose id already exists in `self`.
673    ///
674    /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
675    ///   the rule at the same position in `self`.
676    /// - Rules in `other` with no id, or with an id not present in `self`, are
677    ///   **appended** to `self`.
678    /// - Rules present only in `self` (no matching override in `other`) are
679    ///   **retained unchanged**.
680    ///
681    /// # Errors
682    ///
683    /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
684    /// different release scopes.
685    ///
686    /// # Example
687    ///
688    /// ```rust,ignore
689    /// let base = ProfileRulePack::new("UTILMD-5.4")
690    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* old */ });
691    ///
692    /// let delta = ProfileRulePack::new("UTILMD-5.5-delta")
693    ///     .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* updated */ });
694    ///
695    /// // `result` runs the updated BGM-M rule only once:
696    /// let result = base.merge_with_override(delta)?;
697    /// assert_eq!(result.rule_count(), 1);
698    /// ```
699    pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
700        let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
701        for (idx, rule) in self.rules.iter().enumerate() {
702            if let Some(id) = &rule.id {
703                id_to_index.insert(id.clone(), idx);
704            }
705        }
706
707        let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
708        let mut to_append = Vec::new();
709
710        for other_rule in other.rules.drain(..) {
711            if let Some(id) = &other_rule.id {
712                if let Some(&idx) = id_to_index.get(id) {
713                    replacements.push((idx, other_rule));
714                } else {
715                    to_append.push(other_rule);
716                }
717            } else {
718                to_append.push(other_rule);
719            }
720        }
721
722        for (idx, rule) in replacements {
723            if idx < self.rules.len() {
724                self.rules[idx] = rule;
725            }
726        }
727
728        self.rules.append(&mut to_append);
729        self.message_types.append(&mut other.message_types);
730        // Merge group rules: named overrides replace matching entries; others are appended.
731        let mut group_id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
732        for (idx, rule) in self.group_rules.iter().enumerate() {
733            if let Some(id) = &rule.id {
734                group_id_to_index.insert(id.clone(), idx);
735            }
736        }
737        let mut group_replacements: Vec<(usize, NamedGroupRule)> = Vec::new();
738        let mut group_to_append = Vec::new();
739        for other_rule in other.group_rules.drain(..) {
740            if let Some(id) = &other_rule.id {
741                if let Some(&idx) = group_id_to_index.get(id) {
742                    group_replacements.push((idx, other_rule));
743                } else {
744                    group_to_append.push(other_rule);
745                }
746            } else {
747                group_to_append.push(other_rule);
748            }
749        }
750        for (idx, rule) in group_replacements {
751            if idx < self.group_rules.len() {
752                self.group_rules[idx] = rule;
753            }
754        }
755        self.group_rules.append(&mut group_to_append);
756        self.release = merge_release_scopes(self.release.take(), other.release.take())?;
757        Ok(self)
758    }
759}
760
761pub(super) fn merge_release_scopes(
762    current: Option<String>,
763    incoming: Option<String>,
764) -> Result<Option<String>, EdifactError> {
765    match (current, incoming) {
766        (Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
767            current: x,
768            incoming: y,
769        }),
770        (Some(x), Some(_)) => Ok(Some(x)),
771        (Some(x), None) => Ok(Some(x)),
772        (None, incoming) => Ok(incoming),
773    }
774}
775
776impl Validator for ProfileRulePack {
777    fn validate_batch(
778        &self,
779        segments: &[Segment<'_>],
780        report: &mut ValidationReport,
781        context: &ValidationRuleContext<'_>,
782    ) {
783        // Use the pre-extracted message type from the rule context when available
784        // (set by ValidationContext to avoid per-pack O(n) UNH scans, F-017).
785        let unh_e1_storage;
786        let unh_e1: Option<&crate::model::Element<'_>> = if context.message_type.is_some() {
787            None
788        } else {
789            unh_e1_storage = segments
790                .iter()
791                .find(|s| s.tag == "UNH")
792                .and_then(|s| s.get_element(1));
793            unh_e1_storage
794        };
795
796        let message_type = context
797            .message_type
798            .or_else(|| unh_e1.and_then(|e| e.get_component(0)));
799
800        if !self.message_types.is_empty()
801            && !message_type.is_some_and(|mt| self.message_types.contains(mt))
802        {
803            return;
804        }
805
806        if let Some(bound_release) = &self.release {
807            let msg_association = segments
808                .iter()
809                .find(|s| s.tag == "UNH")
810                .and_then(|s| s.get_element(1))
811                .and_then(|e| e.get_component(4));
812            if msg_association != Some(bound_release.as_str()) {
813                return;
814            }
815        }
816
817        let mut rule_issues: Vec<ValidationIssue> = Vec::new();
818
819        for named in &self.rules {
820            let errors_before = report.errors.len();
821            named.rule.evaluate(segments, context, &mut rule_issues);
822            for issue in rule_issues.drain(..) {
823                match issue.severity {
824                    ValidationSeverity::Critical | ValidationSeverity::Error => {
825                        report.add_error(issue);
826                    }
827                    ValidationSeverity::Warning => {
828                        report.add_warning(issue);
829                    }
830                    ValidationSeverity::Info => {
831                        report.add_info(issue);
832                    }
833                }
834            }
835            if self.bail_on_first_error && report.errors.len() > errors_before {
836                return;
837            }
838        }
839    }
840
841    fn validate_group_batch(
842        &self,
843        root: &SegmentGroupIndexed,
844        all_segments: &[Segment<'_>],
845        report: &mut ValidationReport,
846        context: &ValidationRuleContext<'_>,
847    ) {
848        if self.group_rules.is_empty() {
849            return;
850        }
851
852        // Apply message-type and release scope filters (same as validate_batch).
853        let unh_e1_storage;
854        let unh_e1: Option<&crate::model::Element<'_>> = if context.message_type.is_some() {
855            None
856        } else {
857            unh_e1_storage = all_segments
858                .iter()
859                .find(|s| s.tag == "UNH")
860                .and_then(|s| s.get_element(1));
861            unh_e1_storage
862        };
863        let message_type = context
864            .message_type
865            .or_else(|| unh_e1.and_then(|e| e.get_component(0)));
866
867        if !self.message_types.is_empty()
868            && !message_type.is_some_and(|mt| self.message_types.contains(mt))
869        {
870            return;
871        }
872
873        if let Some(bound_release) = &self.release {
874            let msg_association = all_segments
875                .iter()
876                .find(|s| s.tag == "UNH")
877                .and_then(|s| s.get_element(1))
878                .and_then(|e| e.get_component(4));
879            if msg_association != Some(bound_release.as_str()) {
880                return;
881            }
882        }
883
884        self.walk_group_tree(root, all_segments, report, context);
885    }
886
887    fn fork(&self) -> Box<dyn Validator + Send + Sync> {
888        Box::new(self.clone())
889    }
890}
891
892impl Clone for ProfileRulePack {
893    fn clone(&self) -> Self {
894        Self {
895            name: self.name.clone(),
896            message_types: self.message_types.clone(),
897            release: self.release.clone(),
898            rules: self.rules.clone(),
899            group_rules: self.group_rules.clone(),
900            bail_on_first_error: self.bail_on_first_error,
901        }
902    }
903}
904
905impl std::fmt::Debug for ProfileRulePack {
906    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
907        f.debug_struct("ProfileRulePack")
908            .field("name", &self.name)
909            .field("message_types", &self.message_types)
910            .field("release", &self.release)
911            .field("rule_count", &self.rules.len())
912            .field("group_rule_count", &self.group_rules.len())
913            .field("bail_on_first_error", &self.bail_on_first_error)
914            .finish()
915    }
916}
917
918/// `Arc<ProfileRulePack>` can be plugged directly into a [`super::context::ValidationContext`].
919///
920/// Forking (for `fork_with_message_ref`) only increments the reference count — no
921/// deep copy of the rule vec is performed.  This is the zero-allocation path for
922/// downstream code that caches packs in a `LazyLock` or `OnceLock`.
923///
924/// # Example
925///
926/// ```rust,ignore
927/// use std::sync::{Arc, LazyLock};
928/// use edifact_rs::{ProfileRulePack, ValidationContext};
929///
930/// static ORDERS_PACK: LazyLock<Arc<ProfileRulePack>> = LazyLock::new(|| {
931///     Arc::new(
932///         ProfileRulePack::new("ORDERS-MIG")
933///             .for_message_type("ORDERS")
934///             .require_segment("BGM", "MIG-BGM-M"),
935///     )
936/// });
937///
938/// let ctx = ValidationContext::builder()
939///     .with_profile_pack_arc(Arc::clone(&ORDERS_PACK))
940///     .build();
941/// ```
942impl Validator for Arc<ProfileRulePack> {
943    fn validate_batch(
944        &self,
945        segments: &[Segment<'_>],
946        report: &mut ValidationReport,
947        context: &ValidationRuleContext<'_>,
948    ) {
949        self.as_ref().validate_batch(segments, report, context);
950    }
951
952    fn validate_group_batch(
953        &self,
954        root: &SegmentGroupIndexed,
955        all_segments: &[Segment<'_>],
956        report: &mut ValidationReport,
957        context: &ValidationRuleContext<'_>,
958    ) {
959        self.as_ref()
960            .validate_group_batch(root, all_segments, report, context);
961    }
962
963    fn fork(&self) -> Box<dyn Validator + Send + Sync> {
964        Box::new(Arc::clone(self))
965    }
966}