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