edifact_rs/validator.rs
1//! Validation pipeline for structural and semantic EDIFACT checks.
2
3use crate::{
4 EdifactError, OwnedSegment, Segment, ValidationIssue, ValidationReport, ValidationSeverity,
5};
6use std::any::Any;
7use std::sync::Arc;
8
9/// Typed context injected into profile rule closures at validation time.
10///
11/// Rules access per-call metadata via [`ValidationRuleContext::metadata`] and
12/// the message reference (UNH element 0) via [`ValidationRuleContext::message_ref`].
13///
14/// # Example
15///
16/// ```rust,ignore
17/// let pack = ProfileRulePack::new("AHB-11001")
18/// .with_rule_fn(|segs, ctx, issues| {
19/// // Rule closures return `()` and push into `issues`;
20/// // use `let else` to skip when metadata is absent.
21/// let Some(pruefid) = ctx.metadata::<Pruefid>() else { return };
22/// let msg_ref = ctx.message_ref.unwrap_or("<unknown>");
23/// // use pruefid and msg_ref …
24/// });
25///
26/// let report = ValidationContext::builder()
27/// .with_profile_pack(pack)
28/// .with_message_ref("0001")
29/// .build()
30/// .validate_lenient_with(&segments, &my_pruefid);
31/// ```
32#[derive(Clone, Copy)]
33pub struct ValidationRuleContext<'a> {
34 metadata: Option<&'a (dyn Any + Send + Sync)>,
35 /// Message reference (`UNH` element 0) for this validation call.
36 ///
37 /// Set at build time via [`ValidationContextBuilder::with_message_ref`]. The reference
38 /// is forwarded automatically into every [`ValidationRuleContext`] constructed by
39 /// [`ValidationContext::validate_lenient`] and related methods. `None` when no
40 /// reference was configured.
41 pub message_ref: Option<&'a str>,
42}
43
44impl<'a> ValidationRuleContext<'a> {
45 /// Construct a context with no metadata and no message reference.
46 pub fn empty() -> Self {
47 Self {
48 metadata: None,
49 message_ref: None,
50 }
51 }
52
53 /// Construct a context holding a typed metadata reference.
54 pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
55 Self {
56 metadata: Some(value as &(dyn Any + Send + Sync)),
57 message_ref: None,
58 }
59 }
60
61 /// Attach a message reference to this context (builder-style).
62 pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
63 self.message_ref = Some(msg_ref);
64 self
65 }
66
67 /// Downcast the metadata to `T`. Returns `None` if no metadata was
68 /// injected or if the concrete type does not match `T`.
69 pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
70 self.metadata?.downcast_ref::<T>()
71 }
72
73 /// Return `true` if metadata was provided.
74 pub fn has_metadata(&self) -> bool {
75 self.metadata.is_some()
76 }
77}
78
79impl std::fmt::Debug for ValidationRuleContext<'_> {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("ValidationRuleContext")
82 .field("has_metadata", &self.metadata.is_some())
83 .field("message_ref", &self.message_ref)
84 .finish()
85 }
86}
87
88/// A profile rule that can be added to a [`ProfileRulePack`].
89///
90/// Implement this trait to create reusable, composable profile rules for
91/// EDIFACT message validation. Rules receive a [`ValidationRuleContext`] that
92/// provides optional typed metadata injected at validation call time via
93/// [`ValidationContext::validate_lenient_with`].
94///
95/// # Multiple issues per invocation
96///
97/// [`evaluate`](ProfileRule::evaluate) appends issues into a caller-supplied
98/// `Vec` rather than returning a single `Option`. This lets one rule iterate
99/// every matching segment and report *all* violations — not just the first.
100///
101/// Rules that only ever emit a single issue can still return early:
102///
103/// ```rust,ignore
104/// fn evaluate(&self, segments: &[Segment<'_>], _ctx: &ValidationRuleContext<'_>,
105/// issues: &mut Vec<ValidationIssue>) {
106/// if let Some(problem) = check_something(segments) {
107/// issues.push(problem);
108/// }
109/// }
110/// ```
111///
112/// # `bail_on_first_error` interaction
113///
114/// When [`ProfileRulePack::bail_on_first_error`] is set, the pack stops calling
115/// further rules as soon as this method pushes at least one error-severity issue.
116/// Issues already pushed remain in the report; subsequent rules in the same pack
117/// are skipped.
118pub trait ProfileRule: Send + Sync {
119 /// Evaluate the rule against the given segments.
120 ///
121 /// Push any violations into `issues`. Push nothing if the segments pass.
122 fn evaluate(
123 &self,
124 segments: &[Segment<'_>],
125 context: &ValidationRuleContext<'_>,
126 issues: &mut Vec<ValidationIssue>,
127 );
128}
129
130/// Wraps a context-aware closure as a [`ProfileRule`].
131struct ClosureProfileRule<F>(F);
132
133impl<F> ProfileRule for ClosureProfileRule<F>
134where
135 F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
136 + Send
137 + Sync,
138{
139 fn evaluate(
140 &self,
141 segments: &[Segment<'_>],
142 context: &ValidationRuleContext<'_>,
143 issues: &mut Vec<ValidationIssue>,
144 ) {
145 (self.0)(segments, context, issues);
146 }
147}
148
149/// Wraps a context-free closure as a [`ProfileRule`] (ignores the context parameter).
150struct StatelessClosureProfileRule<F>(F);
151
152impl<F> ProfileRule for StatelessClosureProfileRule<F>
153where
154 F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync,
155{
156 fn evaluate(
157 &self,
158 segments: &[Segment<'_>],
159 _context: &ValidationRuleContext<'_>,
160 issues: &mut Vec<ValidationIssue>,
161 ) {
162 (self.0)(segments, issues);
163 }
164}
165
166/// A rule entry inside a [`ProfileRulePack`], optionally carrying a stable identifier.
167///
168/// The `id` is used by [`ProfileRulePack::merge_with_override`] to de-duplicate rules:
169/// when two packs contain a rule with the same id, the rule from the *other* (override)
170/// pack replaces the one in `self`.
171struct NamedRule {
172 /// Stable identifier for this rule, e.g. `"AHB-11001-BGM-M"`.
173 ///
174 /// `None` for anonymous rules that can never be overridden by id.
175 id: Option<Arc<str>>,
176 rule: Arc<dyn ProfileRule + Send + Sync>,
177}
178
179impl Clone for NamedRule {
180 fn clone(&self) -> Self {
181 Self {
182 id: self.id.clone(),
183 rule: Arc::clone(&self.rule),
184 }
185 }
186}
187
188/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
189pub struct ProfileRulePack {
190 name: String,
191 /// Set of EDIFACT message types this pack is scoped to (e.g. `"ORDERS"`, `"INVOIC"`).
192 ///
193 /// `BTreeSet` provides O(log n) membership tests and deterministic iteration order
194 /// without requiring the `hashbrown` dependency. Profile packs rarely contain more
195 /// than a handful of types, so the difference over a `Vec` is negligible in practice,
196 /// but the semantics (no duplicates, sorted iteration) are more correct.
197 message_types: std::collections::BTreeSet<String>,
198 /// Association-assigned code (DE 0057) this pack is bound to, e.g. `"5.5.3a"`.
199 ///
200 /// `None` means the pack applies universally regardless of association code.
201 release: Option<String>,
202 rules: Vec<NamedRule>,
203 bail_on_first_error: bool,
204}
205
206impl ProfileRulePack {
207 /// Create an empty rule pack.
208 pub fn new(name: impl Into<String>) -> Self {
209 Self {
210 name: name.into(),
211 message_types: std::collections::BTreeSet::new(),
212 release: None,
213 rules: Vec::new(),
214 bail_on_first_error: false,
215 }
216 }
217
218 /// Return the pack name.
219 pub fn name(&self) -> &str {
220 &self.name
221 }
222
223 /// Return the message types this pack is scoped to.
224 pub fn message_types(&self) -> impl Iterator<Item = &str> {
225 self.message_types.iter().map(|s| s.as_str())
226 }
227
228 /// Return the number of rules in this pack.
229 pub fn rule_count(&self) -> usize {
230 self.rules.len()
231 }
232
233 /// Iterate over the stable identifiers of all **named** rules in this pack.
234 ///
235 /// Anonymous rules (added without an id) are skipped.
236 pub fn rule_ids(&self) -> impl Iterator<Item = &str> {
237 self.rules.iter().filter_map(|r| r.id.as_deref())
238 }
239
240 /// Return the association-assigned release code this pack is bound to, if any.
241 ///
242 /// `None` means the pack applies to messages of any association code.
243 pub fn release(&self) -> Option<&str> {
244 self.release.as_deref()
245 }
246
247 /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
248 ///
249 /// When a pack has one or more message-type restrictions, its rules are only evaluated
250 /// against messages whose `UNH` element 1, component 0 matches one of the registered
251 /// types (e.g. `"ORDERS"`, `"INVOIC"`).
252 ///
253 /// # Silent-skip behaviour
254 ///
255 /// If the input segments do not contain a `UNH` segment, or if the `UNH` message-type
256 /// element is absent, the pack will **silently skip all rules** rather than returning an
257 /// error. This is intentional: without a readable message type the pack cannot
258 /// determine whether its rules apply, so it errs on the side of no false positives.
259 ///
260 /// If you need a hard failure on a missing `UNH`, add a dedicated [`ProfileRule`] that
261 /// checks for the segment's presence before other rules run.
262 pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
263 self.message_types.insert(message_type.into());
264 self
265 }
266
267 /// Bind this pack to a specific association-assigned code (DE 0057).
268 ///
269 /// When a release is set, rules are only evaluated against messages whose
270 /// `UNH` element 1, component 4 matches `release` exactly (e.g. `"5.5.3a"`).
271 /// Packs with no bound release are universal — they run for every message
272 /// regardless of its association code.
273 ///
274 /// # Example
275 ///
276 /// ```rust,ignore
277 /// let pack = ProfileRulePack::new("UTILMD-5.5.3a")
278 /// .for_message_type("UTILMD")
279 /// .for_release("5.5.3a");
280 /// ```
281 pub fn for_release(mut self, release: impl Into<String>) -> Self {
282 self.release = Some(release.into());
283 self
284 }
285
286 /// Stop evaluating rules in this pack after the first `Error`- or `Critical`-severity
287 /// finding.
288 ///
289 /// Bail applies *per pack*, not globally — other packs in the
290 /// [`ValidationContext`] still run even when this pack bails early. This
291 /// avoids flooding validation reports with cascading false positives when a
292 /// mandatory segment is missing and all subsequent rules reference its content.
293 pub fn bail_on_first_error(mut self, bail: bool) -> Self {
294 self.bail_on_first_error = bail;
295 self
296 }
297
298 /// Add a context-aware rule closure.
299 ///
300 /// The closure receives the segment slice, a [`ValidationRuleContext`], and a
301 /// `&mut Vec<ValidationIssue>` to push any violations into. Push nothing if
302 /// the segments pass. Multiple issues may be pushed per invocation.
303 ///
304 /// For rules that do not need context, use [`with_stateless_rule_fn`][Self::with_stateless_rule_fn].
305 pub fn with_rule_fn<F>(mut self, rule: F) -> Self
306 where
307 F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
308 + Send
309 + Sync
310 + 'static,
311 {
312 self.rules.push(NamedRule {
313 id: None,
314 rule: Arc::new(ClosureProfileRule(rule)),
315 });
316 self
317 }
318
319 /// Add a context-aware rule closure with a stable identifier.
320 ///
321 /// The `id` is used by [`merge_with_override`][Self::merge_with_override] to de-duplicate
322 /// rules across packs: if `other` has a rule with the same `id`, it replaces the
323 /// corresponding rule in `self`.
324 pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
325 where
326 F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
327 + Send
328 + Sync
329 + 'static,
330 {
331 self.rules.push(NamedRule {
332 id: Some(id.into()),
333 rule: Arc::new(ClosureProfileRule(rule)),
334 });
335 self
336 }
337
338 /// Add a context-free rule closure.
339 ///
340 /// The closure receives the segment slice and a `&mut Vec<ValidationIssue>` to
341 /// push violations into. Convenience wrapper for rules that do not inspect the
342 /// [`ValidationRuleContext`].
343 pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
344 where
345 F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
346 {
347 self.rules.push(NamedRule {
348 id: None,
349 rule: Arc::new(StatelessClosureProfileRule(rule)),
350 });
351 self
352 }
353
354 /// Add a context-free rule closure with a stable identifier.
355 ///
356 /// See [`with_named_rule_fn`][Self::with_named_rule_fn] for override semantics.
357 pub fn with_named_stateless_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
358 where
359 F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
360 {
361 self.rules.push(NamedRule {
362 id: Some(id.into()),
363 rule: Arc::new(StatelessClosureProfileRule(rule)),
364 });
365 self
366 }
367
368 /// Add a rule that implements [`ProfileRule`].
369 pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
370 self.rules.push(NamedRule {
371 id: None,
372 rule: Arc::new(rule),
373 });
374 self
375 }
376
377 /// Add a named rule that implements [`ProfileRule`].
378 ///
379 /// See [`with_named_rule_fn`][Self::with_named_rule_fn] for override semantics.
380 pub fn with_named_rule(
381 mut self,
382 id: impl Into<Arc<str>>,
383 rule: impl ProfileRule + 'static,
384 ) -> Self {
385 self.rules.push(NamedRule {
386 id: Some(id.into()),
387 rule: Arc::new(rule),
388 });
389 self
390 }
391
392 /// Prepend all rules from `base` to this pack.
393 ///
394 /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
395 /// Message-type restrictions from `base` are also merged. The resulting
396 /// release scope must be compatible with both packs: if one pack is scoped
397 /// to a release and the other is not, the scope is preserved; if both are
398 /// scoped, they must match.
399 ///
400 /// # Errors
401 ///
402 /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
403 /// different release scopes. Use
404 /// [`merge_unchecked`][Self::merge_unchecked] in code-generated or
405 /// build-verified contexts where compatibility is guaranteed.
406 ///
407 /// # Example
408 ///
409 /// ```rust,ignore
410 /// let base = ProfileRulePack::new("MIG-UTILMD-BASE")
411 /// .with_stateless_rule_fn(/* mandatory segment rules */);
412 ///
413 /// let ahb_11001 = ProfileRulePack::new("AHB-11001")
414 /// .extend_from(&base)?
415 /// .with_stateless_rule_fn(/* 11001-specific rules */);
416 /// ```
417 pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
418 let mut combined = base.rules.clone();
419 combined.append(&mut self.rules);
420 self.rules = combined;
421 for mt in &base.message_types {
422 self.message_types.insert(mt.clone());
423 }
424 self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
425 Ok(self)
426 }
427
428 /// Merge two packs into one combined pack.
429 ///
430 /// Rules from `self` run before rules from `other`. If both packs contain
431 /// named rules with the same id, **both run** — use
432 /// [`merge_with_override`][Self::merge_with_override] to de-duplicate by id instead.
433 ///
434 /// # Errors
435 ///
436 /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
437 /// different release scopes. Use
438 /// [`merge_unchecked`][Self::merge_unchecked] in code-generated or
439 /// build-verified contexts where compatibility is guaranteed.
440 pub fn merge(mut self, mut other: Self) -> Result<Self, EdifactError> {
441 self.message_types.append(&mut other.message_types);
442 self.release = merge_release_scopes(self.release.take(), other.release.take())?;
443 self.rules.append(&mut other.rules);
444 Ok(self)
445 }
446
447 /// Merge two packs without checking release-scope compatibility.
448 ///
449 /// Identical to [`merge`][Self::merge] except that incompatible release
450 /// scopes do **not** return `Err` — `other`'s release takes precedence when
451 /// both packs specify different values.
452 ///
453 /// Use this in code-generated profiles where compatibility is guaranteed at
454 /// build time and the fallible `Result` return of [`merge`][Self::merge]
455 /// would only add noise.
456 pub fn merge_unchecked(mut self, mut other: Self) -> Self {
457 self.message_types.append(&mut other.message_types);
458 // Let the incoming release win; `None` defers to whichever side has a value.
459 self.release = match (self.release.take(), other.release.take()) {
460 (_, Some(r)) => Some(r),
461 (current, None) => current,
462 };
463 self.rules.append(&mut other.rules);
464 self
465 }
466
467 /// Merge `other` into `self`, with `other` taking precedence for any rule
468 /// whose id already exists in `self`.
469 ///
470 /// - Rules in `other` that have a stable id matching a rule in `self` **replace**
471 /// the rule at the same position in `self`.
472 /// - Rules in `other` with no id, or with an id not present in `self`, are
473 /// **appended** to `self`.
474 /// - Rules present only in `self` (no matching override in `other`) are
475 /// **retained unchanged**.
476 ///
477 /// Message-type restrictions from `other` are merged into `self`.
478 ///
479 /// # Errors
480 ///
481 /// Returns [`EdifactError::IncompatibleReleaseScopes`] if both packs specify
482 /// different release scopes.
483 ///
484 /// # Example
485 ///
486 /// ```rust,ignore
487 /// let base = ProfileRulePack::new("UTILMD-5.4")
488 /// .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* old */ });
489 ///
490 /// let delta = ProfileRulePack::new("UTILMD-5.5-delta")
491 /// .with_named_stateless_rule_fn("AHB-11001-BGM-M", |segs, _issues| { /* updated */ });
492 ///
493 /// // `result` runs the updated BGM-M rule only once:
494 /// let result = base.merge_with_override(delta)?;
495 /// assert_eq!(result.rule_count(), 1);
496 /// ```
497 pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
498 // Build an id→index map for self.rules to avoid O(n*m) behavior.
499 let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
500 for (idx, rule) in self.rules.iter().enumerate() {
501 if let Some(id) = &rule.id {
502 id_to_index.insert(id.clone(), idx);
503 }
504 }
505
506 // Process overrides in a single pass: collect replacements and appends.
507 let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
508 let mut to_append = Vec::new();
509
510 for other_rule in other.rules.drain(..) {
511 if let Some(id) = &other_rule.id {
512 if let Some(&idx) = id_to_index.get(id) {
513 replacements.push((idx, other_rule));
514 } else {
515 to_append.push(other_rule);
516 }
517 } else {
518 to_append.push(other_rule);
519 }
520 }
521
522 // Apply replacements in-place.
523 for (idx, rule) in replacements {
524 if idx < self.rules.len() {
525 self.rules[idx] = rule;
526 }
527 }
528
529 // Append new rules.
530 self.rules.append(&mut to_append);
531
532 self.message_types.append(&mut other.message_types);
533 self.release = merge_release_scopes(self.release.take(), other.release.take())?;
534 Ok(self)
535 }
536}
537
538fn merge_release_scopes(
539 current: Option<String>,
540 incoming: Option<String>,
541) -> Result<Option<String>, EdifactError> {
542 match (current, incoming) {
543 (Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
544 current: x,
545 incoming: y,
546 }),
547 (Some(x), Some(_)) => Ok(Some(x)),
548 (Some(x), None) => Ok(Some(x)),
549 (None, incoming) => Ok(incoming),
550 }
551}
552
553impl Validator for ProfileRulePack {
554 fn validate_batch(
555 &self,
556 segments: &[Segment<'_>],
557 report: &mut ValidationReport,
558 context: &ValidationRuleContext<'_>,
559 ) {
560 let unh = segments.iter().find(|segment| segment.tag == "UNH");
561
562 // Cache UNH element 1 to avoid two separate get_element(1) calls (F-019).
563 let unh_e1 = unh.and_then(|s| s.get_element(1));
564
565 // Message-type filter: skip if no registered type matches.
566 let message_type = unh_e1.and_then(|e| e.get_component(0));
567 if !self.message_types.is_empty()
568 && !message_type.is_some_and(|mt| self.message_types.contains(mt))
569 {
570 return;
571 }
572
573 // Release filter: skip if pack is bound to a specific association code that
574 // does not match the message's UNH DE 0057 (element 1, component 4).
575 if let Some(bound_release) = &self.release {
576 let msg_association = unh_e1.and_then(|e| e.get_component(4));
577 if msg_association != Some(bound_release.as_str()) {
578 return;
579 }
580 }
581
582 // Reusable buffer: avoids a heap allocation per rule invocation on the
583 // no-violation fast path.
584 let mut rule_issues: Vec<ValidationIssue> = Vec::new();
585
586 for named in &self.rules {
587 let errors_before = report.errors.len();
588 named.rule.evaluate(segments, context, &mut rule_issues);
589 for issue in rule_issues.drain(..) {
590 match issue.severity {
591 ValidationSeverity::Critical | ValidationSeverity::Error => {
592 report.add_error(issue);
593 }
594 ValidationSeverity::Warning => {
595 report.add_warning(issue);
596 }
597 ValidationSeverity::Info => {
598 report.add_info(issue);
599 }
600 }
601 }
602 // bail_on_first_error fires at rule-invocation granularity: if this rule
603 // pushed at least one error-severity issue, skip remaining rules.
604 if self.bail_on_first_error && report.errors.len() > errors_before {
605 return;
606 }
607 }
608 }
609}
610
611impl std::fmt::Debug for ProfileRulePack {
612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 f.debug_struct("ProfileRulePack")
614 .field("name", &self.name)
615 .field("message_types", &self.message_types)
616 .field("release", &self.release)
617 .field("rule_count", &self.rules.len())
618 .field("bail_on_first_error", &self.bail_on_first_error)
619 .finish()
620 }
621}
622
623/// Validation layers used by [`ValidationContext`].
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625#[non_exhaustive]
626pub enum ValidationLayer {
627 /// Interchange / message envelope checks (`UNB`/`UNH`/`UNT`/`UNZ` counts).
628 Envelope,
629 /// Directory structure checks (segment presence/order/arity).
630 Structure,
631 /// Directory code-list checks.
632 CodeList,
633 /// Downstream profile-pack checks.
634 Profile,
635}
636
637struct LayeredValidator {
638 layer: ValidationLayer,
639 validator: Box<dyn Validator + Send + Sync>,
640}
641
642/// Runtime validation context for progressive layered validation.
643pub struct ValidationContext {
644 validators: Vec<LayeredValidator>,
645 envelope_enabled: bool,
646 structure_enabled: bool,
647 code_list_enabled: bool,
648 profile_enabled: bool,
649 message_type: Option<String>,
650 /// Injected into every emitted `ValidationIssue` when set.
651 message_ref: Option<String>,
652 metadata: Option<Arc<dyn Any + Send + Sync>>,
653}
654
655/// Builder for [`ValidationContext`].
656#[must_use = "call `.build()` to produce a `ValidationContext`"]
657pub struct ValidationContextBuilder {
658 inner: ValidationContext,
659}
660
661impl Default for ValidationContextBuilder {
662 /// Default context builder.
663 ///
664 /// Structure, code-list, and profile layers are enabled by default.
665 /// The envelope layer is **disabled** by default; call
666 /// [`ValidationContextBuilder::with_envelope_validation`] to enable it.
667 fn default() -> Self {
668 Self::new()
669 }
670}
671
672impl ValidationContextBuilder {
673 /// Create a new context builder.
674 ///
675 /// Structure, code-list, and profile layers are enabled by default.
676 /// The envelope layer is **disabled** by default; call
677 /// [`with_envelope_validation`][Self::with_envelope_validation] to enable it
678 /// and add the built-in [`EnvelopeValidator`] in one step.
679 pub fn new() -> Self {
680 Self {
681 inner: ValidationContext {
682 validators: Vec::new(),
683 envelope_enabled: false,
684 structure_enabled: true,
685 code_list_enabled: true,
686 profile_enabled: true,
687 message_type: None,
688 message_ref: None,
689 metadata: None,
690 },
691 }
692 }
693
694 /// Attach typed metadata accessible to context-aware profile rules.
695 ///
696 /// Rules added with [`ProfileRulePack::with_rule_fn`] receive the metadata
697 /// via [`ValidationRuleContext::metadata`] on every call to
698 /// [`ValidationContext::validate_lenient`].
699 ///
700 /// For per-call metadata that varies between validation invocations, use
701 /// [`ValidationContext::validate_lenient_with`] instead.
702 pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
703 self.inner.metadata = Some(Arc::new(value));
704 self
705 }
706
707 /// Stamp every issue produced by this context with the given message reference.
708 ///
709 /// The message reference corresponds to DE 0062 from the `UNH` segment.
710 /// Use this when validating individual messages from a multi-message
711 /// interchange so that issues in the resulting [`ValidationReport`] can be
712 /// correlated back to the originating `UNH`/`UNT` envelope.
713 pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
714 self.inner.message_ref = Some(message_ref.into());
715 self
716 }
717
718 /// Set message type metadata for downstream validators.
719 pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
720 self.inner.message_type = Some(message_type.into());
721 let configured = self.inner.message_type.as_deref();
722 for layered in &mut self.inner.validators {
723 layered.validator.set_message_type(configured);
724 }
725 self
726 }
727
728 /// Enable/disable structure validators.
729 pub fn structure(mut self, enabled: bool) -> Self {
730 self.inner.structure_enabled = enabled;
731 self
732 }
733
734 /// Enable/disable code-list validators.
735 pub fn code_list(mut self, enabled: bool) -> Self {
736 self.inner.code_list_enabled = enabled;
737 self
738 }
739
740 /// Enable/disable profile validators.
741 pub fn profile(mut self, enabled: bool) -> Self {
742 self.inner.profile_enabled = enabled;
743 self
744 }
745
746 /// Enable/disable envelope layer validators.
747 ///
748 /// Off by default. Call [`with_envelope_validation`][Self::with_envelope_validation]
749 /// to add the built-in [`EnvelopeValidator`] and enable the layer in one step.
750 pub fn envelope(mut self, enabled: bool) -> Self {
751 self.inner.envelope_enabled = enabled;
752 self
753 }
754
755 /// Add the built-in [`EnvelopeValidator`] and enable the envelope layer.
756 ///
757 /// The built-in validator mirrors [`crate::validate_envelope`] but
758 /// translates each structural error into a [`ValidationIssue`] so all
759 /// issues land in the unified [`ValidationReport`] alongside profile and
760 /// directory findings.
761 ///
762 /// # Example
763 ///
764 /// ```rust,ignore
765 /// let report = ValidationContext::builder()
766 /// .with_envelope_validation()
767 /// .with_message_type("ORDERS")
768 /// .build()
769 /// .validate_lenient(&all_segments);
770 /// ```
771 pub fn with_envelope_validation(mut self) -> Self {
772 self.inner.envelope_enabled = true;
773 self.inner.validators.push(LayeredValidator {
774 layer: ValidationLayer::Envelope,
775 validator: Box::new(EnvelopeValidator),
776 });
777 self
778 }
779
780 /// Add a validator assigned to `layer`.
781 pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
782 where
783 V: Validator + 'static,
784 {
785 validator.set_message_type(self.inner.message_type.as_deref());
786 self.inner.validators.push(LayeredValidator {
787 layer,
788 validator: Box::new(validator),
789 });
790 self
791 }
792
793 /// Add a profile rule pack to the profile layer.
794 pub fn with_profile_pack(mut self, mut pack: ProfileRulePack) -> Self {
795 pack.set_message_type(self.inner.message_type.as_deref());
796 self.inner.validators.push(LayeredValidator {
797 layer: ValidationLayer::Profile,
798 validator: Box::new(pack),
799 });
800 self
801 }
802
803 /// Finalize builder and create context.
804 #[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
805 pub fn build(self) -> ValidationContext {
806 self.inner
807 }
808}
809
810impl ValidationContext {
811 /// Start building a validation context.
812 pub fn builder() -> ValidationContextBuilder {
813 ValidationContextBuilder::new()
814 }
815
816 /// Execute validators in lenient mode for enabled layers.
817 ///
818 /// Uses any metadata set via [`ValidationContextBuilder::with_metadata`].
819 /// For per-call metadata, use [`validate_lenient_with`][Self::validate_lenient_with].
820 pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
821 self.validate_with_context(segments, &self.build_rule_context())
822 }
823
824 /// Execute validators with per-call typed metadata.
825 ///
826 /// The metadata is accessible inside context-aware rule closures via
827 /// [`ValidationRuleContext::metadata`]. This is the recommended path when
828 /// a single [`ProfileRulePack`] serves multiple process-variant contexts
829 /// (e.g., one pack per message type, injecting the Pruefidentifikator at
830 /// call time).
831 pub fn validate_lenient_with<T: Any + Send + Sync>(
832 &self,
833 segments: &[Segment<'_>],
834 value: &T,
835 ) -> ValidationReport {
836 let ctx = ValidationRuleContext {
837 metadata: Some(value as &(dyn Any + Send + Sync)),
838 message_ref: self.message_ref.as_deref(),
839 };
840 self.validate_with_context(segments, &ctx)
841 }
842
843 /// Execute validators in strict mode for enabled layers.
844 ///
845 /// Returns `Ok(report)` when validation produces no errors. The `Err` variant
846 /// **also contains the full report** (errors, warnings, and infos) so that
847 /// callers can inspect all issues even on failure.
848 ///
849 /// Warnings do **not** cause this method to return `Err`. Call
850 /// [`validate_lenient`][Self::validate_lenient] if you want to inspect warnings
851 /// without failing on errors.
852 pub fn validate_strict(
853 &self,
854 segments: &[Segment<'_>],
855 ) -> Result<ValidationReport, ValidationReport> {
856 self.validate_lenient(segments).result()
857 }
858
859 /// Execute validators in strict mode with per-call typed metadata.
860 ///
861 /// See [`validate_lenient_with`][Self::validate_lenient_with] for context usage and
862 /// [`validate_strict`][Self::validate_strict] for strict-mode semantics.
863 pub fn validate_strict_with<T: Any + Send + Sync>(
864 &self,
865 segments: &[Segment<'_>],
866 value: &T,
867 ) -> Result<ValidationReport, ValidationReport> {
868 self.validate_lenient_with(segments, value).result()
869 }
870
871 /// Execute validators in lenient mode against an owned-segment slice.
872 ///
873 /// Avoids building a `Vec<Segment<'_>>` for the entire slice up front.
874 /// Instead, segments are converted to `Segment<'_>` on demand, per validator
875 /// layer:
876 ///
877 /// - **Envelope layer**: converts the full slice once (`O(n)` allocations).
878 /// - **Non-envelope layers after envelope ran**: converts only the
879 /// non-service segments (UNB/UNZ/UNG/UNE filtered out) — also `O(n)` but
880 /// a smaller constant.
881 /// - **Non-envelope layers when no envelope ran**: converts the full slice
882 /// once, shared across all remaining layers via a lazy `OnceCell`.
883 ///
884 /// When no layers are enabled this returns an empty [`ValidationReport`]
885 /// without any allocation.
886 pub fn validate_lenient_owned(&self, segments: &[OwnedSegment]) -> ValidationReport {
887 if self.validators.is_empty()
888 && !self.envelope_enabled
889 && !self.structure_enabled
890 && !self.code_list_enabled
891 && !self.profile_enabled
892 {
893 return ValidationReport::default();
894 }
895 self.validate_with_context_owned(segments, &self.build_rule_context())
896 }
897
898 fn build_rule_context(&self) -> ValidationRuleContext<'_> {
899 self.metadata
900 .as_ref()
901 .map(|arc| ValidationRuleContext {
902 metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
903 message_ref: self.message_ref.as_deref(),
904 })
905 .unwrap_or_else(|| ValidationRuleContext {
906 metadata: None,
907 message_ref: self.message_ref.as_deref(),
908 })
909 }
910
911 /// Internal: validate owned segments without the upfront full-slice
912 /// `as_borrowed()` conversion that `validate_lenient_owned` used to require.
913 fn validate_with_context_owned(
914 &self,
915 segments: &[OwnedSegment],
916 context: &ValidationRuleContext<'_>,
917 ) -> ValidationReport {
918 let mut report = ValidationReport::default();
919 // Lazy full-slice borrow — built only when the first non-envelope
920 // validator needs it (i.e. when no envelope validator ran).
921 let mut full_borrowed: Option<Vec<Segment<'_>>> = None;
922 // Lazy filtered borrow — built once when the first non-envelope
923 // validator runs after an envelope pass.
924 let mut filtered_borrowed: Option<Vec<Segment<'_>>> = None;
925 let mut envelope_ran = false;
926
927 for lv in &self.validators {
928 if !self.layer_enabled(lv.layer) {
929 continue;
930 }
931 if lv.layer == ValidationLayer::Envelope {
932 // Convert full slice for envelope validation.
933 let borrowed: Vec<Segment<'_>> = segments.iter().map(|s| s.as_borrowed()).collect();
934 lv.validator.validate_batch(&borrowed, &mut report, context);
935 envelope_ran = true;
936 } else if envelope_ran {
937 // Use filtered slice (no service segments).
938 let active = filtered_borrowed.get_or_insert_with(|| {
939 segments
940 .iter()
941 .filter(|s| !matches!(s.tag.as_str(), "UNB" | "UNZ" | "UNG" | "UNE"))
942 .map(|s| s.as_borrowed())
943 .collect()
944 });
945 lv.validator.validate_batch(active, &mut report, context);
946 } else {
947 // No envelope pass yet — use the full slice, lazily converted.
948 let active = full_borrowed
949 .get_or_insert_with(|| segments.iter().map(|s| s.as_borrowed()).collect());
950 lv.validator.validate_batch(active, &mut report, context);
951 }
952 }
953
954 // Stamp every issue with the message reference if one was configured.
955 if let Some(ref msg_ref) = self.message_ref {
956 for issue in report
957 .errors
958 .iter_mut()
959 .chain(report.warnings.iter_mut())
960 .chain(report.infos.iter_mut())
961 {
962 if issue.message_ref.is_none() {
963 issue.message_ref = Some(msg_ref.clone());
964 }
965 }
966 }
967 report
968 }
969
970 /// Execute validators in strict mode against an owned-segment slice.
971 ///
972 /// Equivalent to [`validate_strict`][Self::validate_strict] but accepts
973 /// `&[OwnedSegment]` directly, avoiding a manual `.as_borrowed()` conversion
974 /// at the call site.
975 pub fn validate_strict_owned(
976 &self,
977 segments: &[OwnedSegment],
978 ) -> Result<ValidationReport, ValidationReport> {
979 self.validate_lenient_owned(segments).result()
980 }
981
982 fn validate_with_context(
983 &self,
984 segments: &[Segment<'_>],
985 context: &ValidationRuleContext<'_>,
986 ) -> ValidationReport {
987 let mut report = ValidationReport::default();
988 // After the envelope layer runs, strip the interchange / functional-group
989 // service segments so they do not reach structure / profile validators that
990 // only understand message-level segments. The allocation is deferred until
991 // the envelope layer is actually present and enabled.
992 let mut filtered: Option<Vec<Segment<'_>>> = None;
993 let mut envelope_ran = false;
994
995 for lv in &self.validators {
996 if !self.layer_enabled(lv.layer) {
997 continue;
998 }
999 // For the envelope layer always use the full unmodified slice.
1000 if lv.layer == ValidationLayer::Envelope {
1001 lv.validator.validate_batch(segments, &mut report, context);
1002 envelope_ran = true;
1003 } else {
1004 // For every other layer: if the envelope ran, use the filtered
1005 // slice that has UNB/UNZ/UNG/UNE removed; otherwise use the
1006 // original slice unchanged.
1007 let active: &[Segment<'_>] = if envelope_ran {
1008 filtered.get_or_insert_with(|| {
1009 segments
1010 .iter()
1011 .filter(|s| !matches!(s.tag, "UNB" | "UNZ" | "UNG" | "UNE"))
1012 .cloned()
1013 .collect()
1014 })
1015 } else {
1016 segments
1017 };
1018 lv.validator.validate_batch(active, &mut report, context);
1019 }
1020 }
1021
1022 // Stamp every issue with the message reference if one was configured.
1023 if let Some(ref msg_ref) = self.message_ref {
1024 for issue in report
1025 .errors
1026 .iter_mut()
1027 .chain(report.warnings.iter_mut())
1028 .chain(report.infos.iter_mut())
1029 {
1030 if issue.message_ref.is_none() {
1031 issue.message_ref = Some(msg_ref.clone());
1032 }
1033 }
1034 }
1035 report
1036 }
1037
1038 /// Message type metadata associated with this context, if provided.
1039 pub fn message_type(&self) -> Option<&str> {
1040 self.message_type.as_deref()
1041 }
1042
1043 /// Message reference (`UNH` element 0) associated with this context, if provided.
1044 pub fn message_ref(&self) -> Option<&str> {
1045 self.message_ref.as_deref()
1046 }
1047
1048 fn layer_enabled(&self, layer: ValidationLayer) -> bool {
1049 match layer {
1050 ValidationLayer::Envelope => self.envelope_enabled,
1051 ValidationLayer::Structure => self.structure_enabled,
1052 ValidationLayer::CodeList => self.code_list_enabled,
1053 ValidationLayer::Profile => self.profile_enabled,
1054 }
1055 }
1056}
1057
1058/// Pluggable validator for parsed EDIFACT segments.
1059///
1060/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
1061/// entire segment sequence and appends issues to a [`ValidationReport`].
1062///
1063/// Validators receive a [`ValidationRuleContext`] that may carry typed metadata
1064/// injected at validation call time. Implementations that do not need the
1065/// context may ignore it.
1066///
1067/// For validators that work segment-by-segment, the convenience function
1068/// [`validate_each`] iterates over the slice and calls a per-segment closure,
1069/// so you only need to implement `validate_batch`:
1070///
1071/// ```rust,ignore
1072/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, _ctx: &ValidationRuleContext<'_>) {
1073/// validate_each(segments, report, |seg| {
1074/// // return Ok(()) or Err(EdifactError::...)
1075/// Ok(())
1076/// });
1077/// }
1078/// ```
1079pub trait Validator: Send + Sync {
1080 /// Validate a full segment set and append issues to `report`.
1081 ///
1082 /// Implementations that do not need the context may ignore the `context` parameter.
1083 fn validate_batch(
1084 &self,
1085 segments: &[Segment<'_>],
1086 report: &mut ValidationReport,
1087 context: &ValidationRuleContext<'_>,
1088 );
1089
1090 /// Configure message-type metadata for validators that support explicit scoping.
1091 fn set_message_type(&mut self, _message_type: Option<&str>) {}
1092}
1093
1094/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
1095/// and converts any `Err` into report entries.
1096///
1097/// Use this in `validate_batch` implementations that work segment-by-segment:
1098///
1099/// ```rust,ignore
1100/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
1101/// validate_each(segments, report, |seg| { /* ... */ Ok(()) });
1102/// }
1103/// ```
1104pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
1105where
1106 F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
1107{
1108 for segment in segments {
1109 if let Err(err) = f(segment) {
1110 report_error(report, err);
1111 }
1112 }
1113}
1114
1115/// Convert a low-level validation error to a user-facing issue and append it.
1116pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
1117 let issue = issue_from_error(err);
1118 match issue.severity {
1119 ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
1120 ValidationSeverity::Warning => report.add_warning(issue),
1121 ValidationSeverity::Info => report.add_info(issue),
1122 }
1123}
1124
1125// ── EnvelopeValidator ─────────────────────────────────────────────────────────
1126
1127/// Built-in validator for EDIFACT interchange envelope structure.
1128///
1129/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
1130/// segment counts. Registered by
1131/// [`ValidationContextBuilder::with_envelope_validation`].
1132///
1133/// The validator translates each [`EdifactError`] from
1134/// [`crate::validate_envelope`] into a [`ValidationIssue`] so that envelope
1135/// findings appear in the unified [`ValidationReport`] alongside structure and
1136/// profile results.
1137pub struct EnvelopeValidator;
1138
1139impl Validator for EnvelopeValidator {
1140 fn validate_batch(
1141 &self,
1142 segments: &[Segment<'_>],
1143 report: &mut ValidationReport,
1144 _ctx: &ValidationRuleContext<'_>,
1145 ) {
1146 if let Err(e) = crate::envelope::validate_envelope(segments) {
1147 report_error(report, e);
1148 }
1149 }
1150}
1151
1152fn issue_from_error(err: EdifactError) -> ValidationIssue {
1153 let code = err.stable_code();
1154 let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
1155 let default_hint = err.recovery_hint();
1156
1157 match err {
1158 EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
1159 issue = issue.with_segment(tag).with_offset(offset);
1160 }
1161 EdifactError::InvalidElementCount { tag, offset, .. } => {
1162 issue = issue.with_segment(tag).with_offset(offset);
1163 }
1164 EdifactError::InvalidComponentCount {
1165 tag,
1166 element_index,
1167 offset,
1168 ..
1169 } => {
1170 issue = issue
1171 .with_segment(tag)
1172 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
1173 .with_offset(offset);
1174 }
1175 EdifactError::InvalidCodeValue {
1176 tag,
1177 element_index,
1178 offset,
1179 suggestion,
1180 ..
1181 } => {
1182 issue = issue
1183 .with_segment(tag)
1184 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
1185 .with_offset(offset);
1186 if let Some(s) = suggestion {
1187 issue = issue.with_suggestion(s);
1188 }
1189 }
1190 EdifactError::MissingSegment { tag, .. } => {
1191 issue = issue.with_segment(tag);
1192 }
1193 EdifactError::QualifierMismatch { tag, offset, .. } => {
1194 issue = issue
1195 .with_segment(tag)
1196 .with_element_index(0)
1197 .with_offset(offset);
1198 }
1199 EdifactError::ConditionalRequirementNotMet {
1200 tag,
1201 element_index,
1202 offset,
1203 ..
1204 } => {
1205 issue = issue
1206 .with_segment(tag)
1207 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
1208 .with_offset(offset);
1209 }
1210 EdifactError::MissingRequiredElement { tag, element_index } => {
1211 issue = issue.with_segment(tag);
1212 if let Ok(idx) = u8::try_from(element_index) {
1213 issue = issue.with_element_index(idx);
1214 }
1215 }
1216 EdifactError::MissingRequiredComponent {
1217 tag,
1218 element_index,
1219 component_index,
1220 } => {
1221 issue = issue.with_segment(tag);
1222 if let Ok(ei) = u8::try_from(element_index) {
1223 issue = issue.with_element_index(ei);
1224 }
1225 if let Ok(ci) = u8::try_from(component_index) {
1226 issue = issue.with_component_index(ci);
1227 }
1228 }
1229 EdifactError::InvalidReleaseSequence { offset }
1230 | EdifactError::InvalidDelimiter { offset, .. }
1231 | EdifactError::InvalidText { offset }
1232 | EdifactError::UnexpectedEof { offset }
1233 | EdifactError::UnexpectedDataToken { offset }
1234 | EdifactError::FunctionalGroupNotSupported { offset } => {
1235 issue = issue.with_offset(offset);
1236 }
1237 _ => {}
1238 }
1239
1240 if issue.suggestion.is_none() {
1241 if let Some(hint) = default_hint {
1242 issue = issue.with_suggestion(hint);
1243 }
1244 }
1245
1246 issue
1247}
1248
1249fn severity_for(err: &EdifactError) -> ValidationSeverity {
1250 match err {
1251 EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
1252 ValidationSeverity::Warning
1253 }
1254 _ => ValidationSeverity::Error,
1255 }
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261 use crate::model::Element;
1262
1263 fn demo_orders_profile_pack() -> ProfileRulePack {
1264 ProfileRulePack::new("ORDERS-DEMO")
1265 .for_message_type("ORDERS")
1266 .with_stateless_rule_fn(|segments, issues| {
1267 issues.extend((|| -> Option<ValidationIssue> {
1268 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
1269 let document_code = bgm.get_element(0)?.get_component(0)?;
1270 (document_code == "220").then(|| {
1271 ValidationIssue::new(
1272 ValidationSeverity::Error,
1273 "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
1274 )
1275 .with_rule_id("DEMO-P001")
1276 .with_segment("BGM")
1277 .with_element_index(0)
1278 .with_suggestion("Use a different BGM document code in this demo pack")
1279 })
1280 })());
1281 })
1282 .with_stateless_rule_fn(|segments, issues| {
1283 issues.extend((|| -> Option<ValidationIssue> {
1284 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
1285 let reference = bgm.get_element(1)?.get_component(0)?;
1286 (reference == "PO123").then(|| {
1287 ValidationIssue::new(
1288 ValidationSeverity::Warning,
1289 "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
1290 )
1291 .with_rule_id("DEMO-P002")
1292 .with_segment("BGM")
1293 .with_element_index(1)
1294 .with_suggestion("Use a non-reserved reference in this demo pack")
1295 })
1296 })());
1297 })
1298 }
1299
1300 struct RejectBgm;
1301
1302 struct WarnBgm;
1303
1304 impl Validator for RejectBgm {
1305 fn validate_batch(
1306 &self,
1307 segments: &[Segment<'_>],
1308 report: &mut ValidationReport,
1309 _context: &ValidationRuleContext<'_>,
1310 ) {
1311 validate_each(segments, report, |segment| {
1312 if segment.tag == "BGM" {
1313 return Err(EdifactError::InvalidSegmentForMessage {
1314 tag: "BGM".to_owned(),
1315 message_type: "TEST".to_owned(),
1316 offset: segment.tag_span.start,
1317 });
1318 }
1319 Ok(())
1320 });
1321 }
1322 }
1323
1324 impl Validator for WarnBgm {
1325 fn validate_batch(
1326 &self,
1327 segments: &[Segment<'_>],
1328 report: &mut ValidationReport,
1329 _context: &ValidationRuleContext<'_>,
1330 ) {
1331 validate_each(segments, report, |segment| {
1332 if segment.tag == "BGM" {
1333 return Err(EdifactError::InvalidCodeValue {
1334 tag: "BGM".to_owned(),
1335 element_index: 0,
1336 value: "XXX".to_owned(),
1337 code_list: "1001".to_owned(),
1338 offset: segment.span.start,
1339 suggestion: None,
1340 });
1341 }
1342 Ok(())
1343 });
1344 }
1345 }
1346
1347 fn test_segment(tag: &'static str) -> Segment<'static> {
1348 Segment {
1349 tag,
1350 span: crate::Span::new(0, 0),
1351 tag_span: crate::Span::new(0, 0),
1352 elements: vec![Element::of(&["x"])],
1353 }
1354 }
1355
1356 #[test]
1357 fn lenient_collects_issues() {
1358 let segments = vec![test_segment("UNH"), test_segment("BGM")];
1359 let mut report = ValidationReport::default();
1360 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
1361 assert!(report.has_errors());
1362 assert_eq!(report.errors().len(), 1);
1363 }
1364
1365 #[test]
1366 fn strict_fails_on_errors() {
1367 let segments = vec![test_segment("BGM")];
1368 let mut report = ValidationReport::default();
1369 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
1370 assert!(report.has_errors());
1371 assert_eq!(report.errors().len(), 1);
1372 }
1373
1374 #[test]
1375 fn context_builder_respects_layer_toggles() {
1376 let segments = vec![test_segment("BGM")];
1377 let ctx = ValidationContext::builder()
1378 .structure(false)
1379 .with_validator(ValidationLayer::Structure, RejectBgm)
1380 .with_validator(ValidationLayer::CodeList, WarnBgm)
1381 .build();
1382
1383 let report = ctx.validate_lenient(&segments);
1384 assert!(!report.has_errors());
1385 assert_eq!(report.warnings().len(), 1);
1386 }
1387
1388 #[test]
1389 fn context_strict_fails_when_structure_enabled() {
1390 let segments = vec![test_segment("BGM")];
1391 let ctx = ValidationContext::builder()
1392 .with_message_type("ORDERS")
1393 .with_validator(ValidationLayer::Structure, RejectBgm)
1394 .build();
1395
1396 assert_eq!(ctx.message_type(), Some("ORDERS"));
1397 let result = ctx.validate_strict(&segments);
1398 assert!(result.is_err());
1399 assert!(result.unwrap_err().has_errors());
1400 }
1401
1402 #[test]
1403 fn report_error_applies_default_recovery_hint() {
1404 let mut report = ValidationReport::default();
1405 report_error(
1406 &mut report,
1407 EdifactError::InvalidReleaseSequence { offset: 9 },
1408 );
1409
1410 let issue = report
1411 .errors()
1412 .first()
1413 .expect("expected one issue in the report");
1414 let hint = issue
1415 .suggestion
1416 .as_deref()
1417 .expect("expected default hint to be set");
1418 assert!(hint.contains("Release character"));
1419 assert_eq!(issue.error_code, Some("E019"));
1420 }
1421
1422 #[test]
1423 fn missing_required_component_maps_metadata_to_issue() {
1424 let mut report = ValidationReport::default();
1425 report_error(
1426 &mut report,
1427 EdifactError::MissingRequiredComponent {
1428 tag: "BGM".to_owned(),
1429 element_index: 2,
1430 component_index: 1,
1431 },
1432 );
1433
1434 let issue = report.errors().first().expect("expected one issue");
1435 assert_eq!(issue.error_code, Some("E021"));
1436 assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
1437 assert_eq!(issue.element_index, Some(2));
1438 assert_eq!(issue.component_index, Some(1));
1439 }
1440
1441 #[test]
1442 fn profile_pack_lenient_collects_profile_rule_issues() {
1443 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
1444 let segments = crate::from_bytes(input)
1445 .collect::<Result<Vec<_>, _>>()
1446 .expect("expected parse success");
1447
1448 let ctx = ValidationContext::builder()
1449 .with_profile_pack(demo_orders_profile_pack())
1450 .build();
1451
1452 let report = ctx.validate_lenient(&segments);
1453 assert!(report.has_errors());
1454 assert!(
1455 report
1456 .errors()
1457 .iter()
1458 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
1459 );
1460 assert!(
1461 report
1462 .warnings()
1463 .iter()
1464 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
1465 );
1466 }
1467
1468 #[test]
1469 fn profile_pack_strict_fails_when_profile_errors_exist() {
1470 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
1471 let segments = crate::from_bytes(input)
1472 .collect::<Result<Vec<_>, _>>()
1473 .expect("expected parse success");
1474
1475 let ctx = ValidationContext::builder()
1476 .with_profile_pack(demo_orders_profile_pack())
1477 .build();
1478 let result = ctx.validate_strict(&segments);
1479 assert!(result.is_err());
1480 assert!(result.unwrap_err().has_errors());
1481 }
1482
1483 // ── bail_on_first_error ──────────────────────────────────────────────────
1484
1485 /// A rule that emits two error-severity issues (one per DTM segment).
1486 fn two_dtm_errors_rule() -> ProfileRulePack {
1487 ProfileRulePack::new("TEST-BAIL")
1488 .with_stateless_rule_fn(|segments, issues| {
1489 // Rule A: emits one error per DTM segment.
1490 for seg in segments.iter().filter(|s| s.tag == "DTM") {
1491 issues.push(
1492 ValidationIssue::new(
1493 ValidationSeverity::Error,
1494 format!("DTM error at offset {}", seg.span.start),
1495 )
1496 .with_rule_id("BAIL-R1")
1497 .with_segment("DTM"),
1498 );
1499 }
1500 })
1501 .with_stateless_rule_fn(|segments, issues| {
1502 // Rule B: never fires; used to verify bail skips this rule.
1503 for seg in segments.iter().filter(|s| s.tag == "BGM") {
1504 issues.push(
1505 ValidationIssue::new(ValidationSeverity::Error, "BGM error")
1506 .with_rule_id("BAIL-R2")
1507 .with_segment(seg.tag),
1508 );
1509 }
1510 })
1511 }
1512
1513 #[test]
1514 fn bail_on_first_error_fires_at_rule_invocation_granularity() {
1515 // Two DTM segments → Rule A emits 2 errors for them.
1516 // With bail, Rule B (BGM check) must NOT run.
1517 let input =
1518 b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
1519 let segments = crate::from_bytes(input)
1520 .collect::<Result<Vec<_>, _>>()
1521 .expect("parse failed");
1522
1523 let pack_with_bail = two_dtm_errors_rule().bail_on_first_error(true);
1524 let ctx = ValidationContext::builder()
1525 .with_profile_pack(pack_with_bail)
1526 .build();
1527 let report = ctx.validate_lenient(&segments);
1528
1529 // Rule A fires: both DTM errors are in the report (the whole rule invocation
1530 // runs to completion before bail is checked).
1531 assert_eq!(
1532 report
1533 .errors()
1534 .iter()
1535 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
1536 .count(),
1537 2,
1538 "both DTM errors from Rule A should be present"
1539 );
1540 // Bail fired after Rule A: Rule B (BGM) must be skipped.
1541 assert_eq!(
1542 report
1543 .errors()
1544 .iter()
1545 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
1546 .count(),
1547 0,
1548 "Rule B should have been skipped by bail"
1549 );
1550 }
1551
1552 #[test]
1553 fn bail_disabled_runs_all_rules() {
1554 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
1555 let segments = crate::from_bytes(input)
1556 .collect::<Result<Vec<_>, _>>()
1557 .expect("parse failed");
1558
1559 let pack_no_bail = two_dtm_errors_rule(); // bail_on_first_error defaults to false
1560 let ctx = ValidationContext::builder()
1561 .with_profile_pack(pack_no_bail)
1562 .build();
1563 let report = ctx.validate_lenient(&segments);
1564
1565 // Both rules run: one DTM error from Rule A, one BGM error from Rule B.
1566 assert_eq!(
1567 report
1568 .errors()
1569 .iter()
1570 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
1571 .count(),
1572 1
1573 );
1574 assert_eq!(
1575 report
1576 .errors()
1577 .iter()
1578 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
1579 .count(),
1580 1
1581 );
1582 }
1583
1584 // ── message_ref in ValidationRuleContext ─────────────────────────────────
1585
1586 #[test]
1587 fn message_ref_is_visible_inside_rule_closure() {
1588 let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
1589 let segments = crate::from_bytes(input)
1590 .collect::<Result<Vec<_>, _>>()
1591 .expect("parse failed");
1592
1593 let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
1594 if let Some(mref) = ctx.message_ref {
1595 issues.push(
1596 ValidationIssue::new(
1597 ValidationSeverity::Info,
1598 format!("validating message {mref}"),
1599 )
1600 .with_rule_id("CTX-REF"),
1601 );
1602 }
1603 });
1604
1605 let ctx = ValidationContext::builder()
1606 .with_profile_pack(pack)
1607 .with_message_ref("MSG001")
1608 .build();
1609
1610 let report = ctx.validate_lenient(&segments);
1611 let info = report
1612 .infos()
1613 .iter()
1614 .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
1615 .expect("expected info issue from CTX-REF rule");
1616 assert!(info.message.contains("MSG001"));
1617 // The message_ref is also stamped onto the issue itself.
1618 assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
1619 }
1620}