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