Skip to main content

edifact_rs/
validator.rs

1//! Validation pipeline for structural and semantic EDIFACT checks.
2
3use crate::{EdifactError, Segment, ValidationIssue, ValidationReport, ValidationSeverity};
4use std::any::Any;
5use std::sync::Arc;
6
7/// Typed context injected into profile rule closures at validation time.
8///
9/// Rules access per-call metadata via [`ValidationRuleContext::metadata`].
10/// If no metadata was injected, every `metadata()` call returns `None`.
11///
12/// # Example
13///
14/// ```rust,ignore
15/// let pack = ProfileRulePack::new("AHB-11001")
16///     .with_rule_fn(|segs, ctx| {
17///         let pruefid: &Pruefid = ctx.metadata()?;
18///         // use pruefid …
19///         None
20///     });
21///
22/// let report = ValidationContext::builder()
23///     .with_profile_pack(pack)
24///     .build()
25///     .validate_lenient_with(&segments, &my_pruefid);
26/// ```
27#[derive(Clone, Copy)]
28pub struct ValidationRuleContext<'a> {
29    metadata: Option<&'a (dyn Any + Send + Sync)>,
30}
31
32impl<'a> ValidationRuleContext<'a> {
33    /// Construct a context with no metadata.
34    pub fn empty() -> Self {
35        Self { metadata: None }
36    }
37
38    /// Construct a context holding a typed metadata reference.
39    pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
40        Self {
41            metadata: Some(value as &(dyn Any + Send + Sync)),
42        }
43    }
44
45    /// Downcast the metadata to `T`.  Returns `None` if no metadata was
46    /// injected or if the concrete type does not match `T`.
47    pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
48        self.metadata?.downcast_ref::<T>()
49    }
50
51    /// Return `true` if metadata was provided.
52    pub fn has_metadata(&self) -> bool {
53        self.metadata.is_some()
54    }
55}
56
57impl std::fmt::Debug for ValidationRuleContext<'_> {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("ValidationRuleContext")
60            .field("has_metadata", &self.metadata.is_some())
61            .finish()
62    }
63}
64
65/// A profile rule that can be added to a [`ProfileRulePack`].
66///
67/// Implement this trait to create reusable, composable profile rules for
68/// EDIFACT message validation.  Rules receive a [`ValidationRuleContext`] that
69/// provides optional typed metadata injected at validation call time via
70/// [`ValidationContext::validate_lenient_with`].
71pub trait ProfileRule: Send + Sync {
72    /// Evaluate the rule against the given segments.
73    ///
74    /// Return `Some(issue)` if the rule is violated, or `None` if the segments pass.
75    fn evaluate(&self, segments: &[Segment<'_>], context: &ValidationRuleContext<'_>) -> Option<ValidationIssue>;
76}
77
78/// Wraps a context-aware closure as a [`ProfileRule`].
79struct ClosureProfileRule<F>(F);
80
81impl<F> ProfileRule for ClosureProfileRule<F>
82where
83    F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>) -> Option<ValidationIssue>
84        + Send
85        + Sync,
86{
87    fn evaluate(&self, segments: &[Segment<'_>], context: &ValidationRuleContext<'_>) -> Option<ValidationIssue> {
88        (self.0)(segments, context)
89    }
90}
91
92/// Wraps a context-free closure as a [`ProfileRule`] (ignores the context parameter).
93struct StatelessClosureProfileRule<F>(F);
94
95impl<F> ProfileRule for StatelessClosureProfileRule<F>
96where
97    F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync,
98{
99    fn evaluate(&self, segments: &[Segment<'_>], _context: &ValidationRuleContext<'_>) -> Option<ValidationIssue> {
100        (self.0)(segments)
101    }
102}
103
104/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
105pub struct ProfileRulePack {
106    name: String,
107    message_types: Vec<String>,
108    rules: Vec<Arc<dyn ProfileRule + Send + Sync>>,
109    bail_on_first_error: bool,
110}
111
112impl ProfileRulePack {
113    /// Create an empty rule pack.
114    pub fn new(name: impl Into<String>) -> Self {
115        Self {
116            name: name.into(),
117            message_types: Vec::new(),
118            rules: Vec::new(),
119            bail_on_first_error: false,
120        }
121    }
122
123    /// Return the pack name.
124    pub fn name(&self) -> &str {
125        &self.name
126    }
127
128    /// Return the message types this pack is scoped to.
129    pub fn message_types(&self) -> &[String] {
130        &self.message_types
131    }
132
133    /// Return the number of rules in this pack.
134    pub fn rule_count(&self) -> usize {
135        self.rules.len()
136    }
137
138    /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
139    ///
140    /// When a pack has one or more message-type restrictions, its rules are only evaluated
141    /// against messages whose `UNH` element 1, component 0 matches one of the registered
142    /// types (e.g. `"ORDERS"`, `"INVOIC"`).
143    ///
144    /// # Silent-skip behaviour
145    ///
146    /// If the input segments do not contain a `UNH` segment, or if the `UNH` message-type
147    /// element is absent, the pack will **silently skip all rules** rather than returning an
148    /// error.  This is intentional: without a readable message type the pack cannot
149    /// determine whether its rules apply, so it errs on the side of no false positives.
150    ///
151    /// If you need a hard failure on a missing `UNH`, add a dedicated [`ProfileRule`] that
152    /// checks for the segment's presence before other rules run.
153    pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
154        let message_type = message_type.into();
155        if !self.message_types.contains(&message_type) {
156            self.message_types.push(message_type);
157        }
158        self
159    }
160
161    /// Stop evaluating rules in this pack after the first `Error`- or `Critical`-severity
162    /// finding.
163    ///
164    /// Bail applies *per pack*, not globally — other packs in the
165    /// [`ValidationContext`] still run even when this pack bails early.  This
166    /// avoids flooding validation reports with cascading false positives when a
167    /// mandatory segment is missing and all subsequent rules reference its content.
168    pub fn bail_on_first_error(mut self, bail: bool) -> Self {
169        self.bail_on_first_error = bail;
170        self
171    }
172
173    /// Add a context-aware rule closure.
174    ///
175    /// The closure receives both the segment slice and a [`ValidationRuleContext`]
176    /// that may carry typed metadata injected at validation call time via
177    /// [`ValidationContext::validate_lenient_with`].
178    ///
179    /// For rules that do not need context, use [`with_stateless_rule_fn`][Self::with_stateless_rule_fn].
180    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
181    where
182        F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>) -> Option<ValidationIssue>
183            + Send
184            + Sync
185            + 'static,
186    {
187        self.rules.push(Arc::new(ClosureProfileRule(rule)));
188        self
189    }
190
191    /// Add a context-free rule closure.
192    ///
193    /// Convenience wrapper for rules that do not inspect the
194    /// [`ValidationRuleContext`].
195    pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
196    where
197        F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync + 'static,
198    {
199        self.rules.push(Arc::new(StatelessClosureProfileRule(rule)));
200        self
201    }
202
203    /// Add a rule that implements [`ProfileRule`].
204    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
205        self.rules.push(Arc::new(rule));
206        self
207    }
208
209    /// Prepend all rules from `base` to this pack.
210    ///
211    /// Rules from `base` are shared (via [`Arc`] cloning) and run first.
212    /// Message-type restrictions from `base` are also merged.
213    ///
214    /// # Example
215    ///
216    /// ```rust,ignore
217    /// let base = ProfileRulePack::new("MIG-UTILMD-BASE")
218    ///     .with_stateless_rule_fn(/* mandatory segment rules */);
219    ///
220    /// let ahb_11001 = ProfileRulePack::new("AHB-11001")
221    ///     .extend_from(&base)
222    ///     .with_stateless_rule_fn(/* 11001-specific rules */);
223    /// ```
224    pub fn extend_from(mut self, base: &ProfileRulePack) -> Self {
225        let mut combined = base.rules.clone();
226        combined.append(&mut self.rules);
227        self.rules = combined;
228        for mt in &base.message_types {
229            if !self.message_types.contains(mt) {
230                self.message_types.push(mt.clone());
231            }
232        }
233        self
234    }
235
236    /// Merge two packs into one combined pack.
237    ///
238    /// Rules from `self` run before rules from `other`.
239    pub fn merge(mut self, mut other: Self) -> Self {
240        for message_type in other.message_types.drain(..) {
241            if !self.message_types.contains(&message_type) {
242                self.message_types.push(message_type);
243            }
244        }
245        self.rules.append(&mut other.rules);
246        self
247    }
248}
249
250impl Validator for ProfileRulePack {
251    fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, context: &ValidationRuleContext<'_>) {
252        let message_type = segments
253            .iter()
254            .find(|segment| segment.tag == "UNH")
255            .and_then(|segment| segment.get_element(1))
256            .and_then(|element| element.get_component(0));
257        if !self.message_types.is_empty()
258            && !message_type.is_some_and(|mt| self.message_types.iter().any(|t| t == mt))
259        {
260            return;
261        }
262
263        for rule in &self.rules {
264            if let Some(issue) = rule.evaluate(segments, context) {
265                let was_error = match issue.severity {
266                    ValidationSeverity::Critical | ValidationSeverity::Error => {
267                        report.add_error(issue);
268                        true
269                    }
270                    ValidationSeverity::Warning => {
271                        report.add_warning(issue);
272                        false
273                    }
274                    ValidationSeverity::Info => {
275                        report.add_info(issue);
276                        false
277                    }
278                };
279                if self.bail_on_first_error && was_error {
280                    return;
281                }
282            }
283        }
284    }
285}
286
287impl std::fmt::Debug for ProfileRulePack {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        f.debug_struct("ProfileRulePack")
290            .field("name", &self.name)
291            .field("message_types", &self.message_types)
292            .field("rule_count", &self.rules.len())
293            .field("bail_on_first_error", &self.bail_on_first_error)
294            .finish()
295    }
296}
297
298/// Validation layers used by [`ValidationContext`].
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300#[non_exhaustive]
301pub enum ValidationLayer {
302    /// Directory structure checks (segment presence/order/arity).
303    Structure,
304    /// Directory code-list checks.
305    CodeList,
306    /// Downstream profile-pack checks.
307    Profile,
308}
309
310struct LayeredValidator {
311    layer: ValidationLayer,
312    validator: Box<dyn Validator + Send + Sync>,
313}
314
315/// Runtime validation context for progressive layered validation.
316pub struct ValidationContext {
317    validators: Vec<LayeredValidator>,
318    structure_enabled: bool,
319    code_list_enabled: bool,
320    profile_enabled: bool,
321    message_type: Option<String>,
322    metadata: Option<Arc<dyn Any + Send + Sync>>,
323}
324
325/// Builder for [`ValidationContext`].
326#[must_use = "call `.build()` to produce a `ValidationContext`"]
327pub struct ValidationContextBuilder {
328    inner: ValidationContext,
329}
330
331impl Default for ValidationContextBuilder {
332    /// Default context builder has all layers enabled, same as [`ValidationContextBuilder::new`].
333    fn default() -> Self {
334        Self::new()
335    }
336}
337
338impl ValidationContextBuilder {
339    /// Create a new context builder with all layers enabled.
340    pub fn new() -> Self {
341        Self {
342            inner: ValidationContext {
343                validators: Vec::new(),
344                structure_enabled: true,
345                code_list_enabled: true,
346                profile_enabled: true,
347                message_type: None,
348                metadata: None,
349            },
350        }
351    }
352
353    /// Attach typed metadata accessible to context-aware profile rules.
354    ///
355    /// Rules added with [`ProfileRulePack::with_rule_fn`] receive the metadata
356    /// via [`ValidationRuleContext::metadata`] on every call to
357    /// [`ValidationContext::validate_lenient`].
358    ///
359    /// For per-call metadata that varies between validation invocations, use
360    /// [`ValidationContext::validate_lenient_with`] instead.
361    pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
362        self.inner.metadata = Some(Arc::new(value));
363        self
364    }
365
366    /// Set message type metadata for downstream validators.
367    pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
368        self.inner.message_type = Some(message_type.into());
369        let configured = self.inner.message_type.as_deref();
370        for layered in &mut self.inner.validators {
371            layered.validator.set_message_type(configured);
372        }
373        self
374    }
375
376    /// Enable/disable structure validators.
377    pub fn structure(mut self, enabled: bool) -> Self {
378        self.inner.structure_enabled = enabled;
379        self
380    }
381
382    /// Enable/disable code-list validators.
383    pub fn code_list(mut self, enabled: bool) -> Self {
384        self.inner.code_list_enabled = enabled;
385        self
386    }
387
388    /// Enable/disable profile validators.
389    pub fn profile(mut self, enabled: bool) -> Self {
390        self.inner.profile_enabled = enabled;
391        self
392    }
393
394    /// Add a validator assigned to `layer`.
395    pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
396    where
397        V: Validator + 'static,
398    {
399        validator.set_message_type(self.inner.message_type.as_deref());
400        self.inner.validators.push(LayeredValidator {
401            layer,
402            validator: Box::new(validator),
403        });
404        self
405    }
406
407    /// Add a profile rule pack to the profile layer.
408    pub fn with_profile_pack(mut self, mut pack: ProfileRulePack) -> Self {
409        pack.set_message_type(self.inner.message_type.as_deref());
410        self.inner.validators.push(LayeredValidator {
411            layer: ValidationLayer::Profile,
412            validator: Box::new(pack),
413        });
414        self
415    }
416
417    /// Finalize builder and create context.
418    #[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
419    pub fn build(self) -> ValidationContext {
420        self.inner
421    }
422}
423
424impl ValidationContext {
425    /// Start building a validation context.
426    pub fn builder() -> ValidationContextBuilder {
427        ValidationContextBuilder::new()
428    }
429
430    /// Execute validators in lenient mode for enabled layers.
431    ///
432    /// Uses any metadata set via [`ValidationContextBuilder::with_metadata`].
433    /// For per-call metadata, use [`validate_lenient_with`][Self::validate_lenient_with].
434    pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
435        let ctx = self
436            .metadata
437            .as_ref()
438            .map(|arc| ValidationRuleContext {
439                metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
440            })
441            .unwrap_or_else(ValidationRuleContext::empty);
442        self.validate_with_context(segments, &ctx)
443    }
444
445    /// Execute validators with per-call typed metadata.
446    ///
447    /// The metadata is accessible inside context-aware rule closures via
448    /// [`ValidationRuleContext::metadata`].  This is the recommended path when
449    /// a single [`ProfileRulePack`] serves multiple process-variant contexts
450    /// (e.g., one pack per message type, injecting the Pruefidentifikator at
451    /// call time).
452    pub fn validate_lenient_with<T: Any + Send + Sync>(
453        &self,
454        segments: &[Segment<'_>],
455        value: &T,
456    ) -> ValidationReport {
457        let ctx = ValidationRuleContext::new(value);
458        self.validate_with_context(segments, &ctx)
459    }
460
461    /// Execute validators in strict mode for enabled layers.
462    ///
463    /// Returns `Ok(report)` when validation produces no errors.  The returned
464    /// report may still contain warnings and infos — warnings do **not** cause
465    /// this method to return `Err`.  Call [`validate_lenient`][Self::validate_lenient]
466    /// if you want to inspect warnings without failing on errors.
467    pub fn validate_strict(
468        &self,
469        segments: &[Segment<'_>],
470    ) -> Result<ValidationReport, EdifactError> {
471        let report = self.validate_lenient(segments);
472        Self::strict_check(report)
473    }
474
475    /// Execute validators in strict mode with per-call typed metadata.
476    ///
477    /// See [`validate_lenient_with`][Self::validate_lenient_with] for context usage and
478    /// [`validate_strict`][Self::validate_strict] for strict-mode semantics.
479    pub fn validate_strict_with<T: Any + Send + Sync>(
480        &self,
481        segments: &[Segment<'_>],
482        value: &T,
483    ) -> Result<ValidationReport, EdifactError> {
484        let report = self.validate_lenient_with(segments, value);
485        Self::strict_check(report)
486    }
487
488    fn validate_with_context(
489        &self,
490        segments: &[Segment<'_>],
491        context: &ValidationRuleContext<'_>,
492    ) -> ValidationReport {
493        let mut report = ValidationReport::default();
494        for lv in &self.validators {
495            if self.layer_enabled(lv.layer) {
496                lv.validator.validate_batch(segments, &mut report, context);
497            }
498        }
499        report
500    }
501
502    fn strict_check(report: ValidationReport) -> Result<ValidationReport, EdifactError> {
503        if report.has_errors() {
504            let first_message = report
505                .errors()
506                .first()
507                .map(|e| e.message.clone())
508                .unwrap_or_else(|| "unknown validation failure".to_owned());
509            return Err(EdifactError::ValidationFailed {
510                error_count: report.errors().len(),
511                first_message,
512            });
513        }
514        Ok(report)
515    }
516
517    /// Message type metadata associated with this context, if provided.
518    pub fn message_type(&self) -> Option<&str> {
519        self.message_type.as_deref()
520    }
521
522    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
523        match layer {
524            ValidationLayer::Structure => self.structure_enabled,
525            ValidationLayer::CodeList => self.code_list_enabled,
526            ValidationLayer::Profile => self.profile_enabled,
527        }
528    }
529}
530
531/// Pluggable validator for parsed EDIFACT segments.
532///
533/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
534/// entire segment sequence and appends issues to a [`ValidationReport`].
535///
536/// Validators receive a [`ValidationRuleContext`] that may carry typed metadata
537/// injected at validation call time.  Implementations that do not need the
538/// context may ignore it.
539///
540/// For validators that work segment-by-segment, the convenience function
541/// [`validate_each`] iterates over the slice and calls a per-segment closure,
542/// so you only need to implement `validate_batch`:
543///
544/// ```rust,ignore
545/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, _ctx: &ValidationRuleContext<'_>) {
546///     validate_each(segments, report, |seg| {
547///         // return Ok(()) or Err(EdifactError::...)
548///         Ok(())
549///     });
550/// }
551/// ```
552pub trait Validator: Send + Sync {
553    /// Validate a full segment set and append issues to `report`.
554    ///
555    /// Implementations that do not need the context may ignore the `context` parameter.
556    fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, context: &ValidationRuleContext<'_>);
557
558    /// Configure message-type metadata for validators that support explicit scoping.
559    fn set_message_type(&mut self, _message_type: Option<&str>) {}
560}
561
562/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
563/// and converts any `Err` into report entries.
564///
565/// Use this in `validate_batch` implementations that work segment-by-segment:
566///
567/// ```rust,ignore
568/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
569///     validate_each(segments, report, |seg| { /* ... */ Ok(()) });
570/// }
571/// ```
572pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
573where
574    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
575{
576    for segment in segments {
577        if let Err(err) = f(segment) {
578            report_error(report, err);
579        }
580    }
581}
582
583/// Convert a low-level validation error to a user-facing issue and append it.
584pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
585    let issue = issue_from_error(err);
586    match issue.severity {
587        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
588        ValidationSeverity::Warning => report.add_warning(issue),
589        ValidationSeverity::Info => report.add_info(issue),
590    }
591}
592
593fn issue_from_error(err: EdifactError) -> ValidationIssue {
594    let code = err.stable_code();
595    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
596    let default_hint = err.recovery_hint();
597
598    match err {
599        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
600            issue = issue.with_segment(tag).with_offset(offset);
601        }
602        EdifactError::InvalidElementCount { tag, offset, .. } => {
603            issue = issue.with_segment(tag).with_offset(offset);
604        }
605        EdifactError::InvalidComponentCount {
606            tag,
607            element_index,
608            offset,
609            ..
610        } => {
611            issue = issue
612                .with_segment(tag)
613                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
614                .with_offset(offset);
615        }
616        EdifactError::InvalidCodeValue {
617            tag,
618            element_index,
619            offset,
620            suggestion,
621            ..
622        } => {
623            issue = issue
624                .with_segment(tag)
625                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
626                .with_offset(offset);
627            if let Some(s) = suggestion {
628                issue = issue.with_suggestion(s);
629            }
630        }
631        EdifactError::MissingSegment { tag, .. } => {
632            issue = issue.with_segment(tag);
633        }
634        EdifactError::QualifierMismatch { tag, offset, .. } => {
635            issue = issue
636                .with_segment(tag)
637                .with_element_index(0)
638                .with_offset(offset);
639        }
640        EdifactError::ConditionalRequirementNotMet {
641            tag,
642            element_index,
643            offset,
644            ..
645        } => {
646            issue = issue
647                .with_segment(tag)
648                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
649                .with_offset(offset);
650        }
651        EdifactError::MissingRequiredElement { tag, element_index } => {
652            issue = issue.with_segment(tag);
653            if let Ok(idx) = u8::try_from(element_index) {
654                issue = issue.with_element_index(idx);
655            }
656        }
657        EdifactError::MissingRequiredComponent {
658            tag,
659            element_index,
660            component_index,
661        } => {
662            issue = issue.with_segment(tag);
663            if let Ok(ei) = u8::try_from(element_index) {
664                issue = issue.with_element_index(ei);
665            }
666            if let Ok(ci) = u8::try_from(component_index) {
667                issue = issue.with_component_index(ci);
668            }
669        }
670        EdifactError::InvalidReleaseSequence { offset }
671        | EdifactError::InvalidDelimiter { offset, .. }
672        | EdifactError::InvalidText { offset }
673        | EdifactError::UnexpectedEof { offset } => {
674            issue = issue.with_offset(offset);
675        }
676        _ => {}
677    }
678
679    if issue.suggestion.is_none() {
680        if let Some(hint) = default_hint {
681            issue = issue.with_suggestion(hint);
682        }
683    }
684
685    issue
686}
687
688fn severity_for(err: &EdifactError) -> ValidationSeverity {
689    match err {
690        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
691            ValidationSeverity::Warning
692        }
693        _ => ValidationSeverity::Error,
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::model::Element;
701
702    fn demo_orders_profile_pack() -> ProfileRulePack {
703        ProfileRulePack::new("ORDERS-DEMO")
704            .for_message_type("ORDERS")
705            .with_stateless_rule_fn(|segments| {
706                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
707                let document_code = bgm.get_element(0)?.get_component(0)?;
708                (document_code == "220").then(|| {
709                    ValidationIssue::new(
710                        ValidationSeverity::Error,
711                        "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
712                    )
713                    .with_rule_id("DEMO-P001")
714                    .with_segment("BGM")
715                    .with_element_index(0)
716                    .with_suggestion("Use a different BGM document code in this demo pack")
717                })
718            })
719            .with_stateless_rule_fn(|segments| {
720                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
721                let reference = bgm.get_element(1)?.get_component(0)?;
722                (reference == "PO123").then(|| {
723                    ValidationIssue::new(
724                        ValidationSeverity::Warning,
725                        "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
726                    )
727                    .with_rule_id("DEMO-P002")
728                    .with_segment("BGM")
729                    .with_element_index(1)
730                    .with_suggestion("Use a non-reserved reference in this demo pack")
731                })
732            })
733    }
734
735    struct RejectBgm;
736
737    struct WarnBgm;
738
739    impl Validator for RejectBgm {
740        fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, _context: &ValidationRuleContext<'_>) {
741            validate_each(segments, report, |segment| {
742                if segment.tag == "BGM" {
743                    return Err(EdifactError::InvalidSegmentForMessage {
744                        tag: "BGM".to_owned(),
745                        message_type: "TEST".to_owned(),
746                        offset: segment.tag_span.start,
747                    });
748                }
749                Ok(())
750            });
751        }
752    }
753
754    impl Validator for WarnBgm {
755        fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport, _context: &ValidationRuleContext<'_>) {
756            validate_each(segments, report, |segment| {
757                if segment.tag == "BGM" {
758                    return Err(EdifactError::InvalidCodeValue {
759                        tag: "BGM".to_owned(),
760                        element_index: 0,
761                        value: "XXX".to_owned(),
762                        code_list: "1001".to_owned(),
763                        offset: segment.span.start,
764                        suggestion: None,
765                    });
766                }
767                Ok(())
768            });
769        }
770    }
771
772    fn test_segment(tag: &'static str) -> Segment<'static> {
773        Segment {
774            tag,
775            span: crate::Span::new(0, 0),
776            tag_span: crate::Span::new(0, 0),
777            elements: vec![Element::of(&["x"])],
778        }
779    }
780
781    #[test]
782    fn lenient_collects_issues() {
783        let segments = vec![test_segment("UNH"), test_segment("BGM")];
784        let mut report = ValidationReport::default();
785        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
786        assert!(report.has_errors());
787        assert_eq!(report.errors().len(), 1);
788    }
789
790    #[test]
791    fn strict_fails_on_errors() {
792        let segments = vec![test_segment("BGM")];
793        let mut report = ValidationReport::default();
794        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
795        assert!(report.has_errors());
796        assert_eq!(report.errors().len(), 1);
797    }
798
799    #[test]
800    fn context_builder_respects_layer_toggles() {
801        let segments = vec![test_segment("BGM")];
802        let ctx = ValidationContext::builder()
803            .structure(false)
804            .with_validator(ValidationLayer::Structure, RejectBgm)
805            .with_validator(ValidationLayer::CodeList, WarnBgm)
806            .build();
807
808        let report = ctx.validate_lenient(&segments);
809        assert!(!report.has_errors());
810        assert_eq!(report.warnings().len(), 1);
811    }
812
813    #[test]
814    fn context_strict_fails_when_structure_enabled() {
815        let segments = vec![test_segment("BGM")];
816        let ctx = ValidationContext::builder()
817            .with_message_type("ORDERS")
818            .with_validator(ValidationLayer::Structure, RejectBgm)
819            .build();
820
821        assert_eq!(ctx.message_type(), Some("ORDERS"));
822        let result = ctx.validate_strict(&segments);
823        assert!(matches!(result, Err(EdifactError::ValidationFailed { .. })));
824    }
825
826    #[test]
827    fn report_error_applies_default_recovery_hint() {
828        let mut report = ValidationReport::default();
829        report_error(
830            &mut report,
831            EdifactError::InvalidReleaseSequence { offset: 9 },
832        );
833
834        let issue = report
835            .errors()
836            .first()
837            .expect("expected one issue in the report");
838        let hint = issue
839            .suggestion
840            .as_deref()
841            .expect("expected default hint to be set");
842        assert!(hint.contains("Release character"));
843        assert_eq!(issue.error_code, Some("E019"));
844    }
845
846    #[test]
847    fn missing_required_component_maps_metadata_to_issue() {
848        let mut report = ValidationReport::default();
849        report_error(
850            &mut report,
851            EdifactError::MissingRequiredComponent {
852                tag: "BGM".to_owned(),
853                element_index: 2,
854                component_index: 1,
855            },
856        );
857
858        let issue = report
859            .errors()
860            .first()
861            .expect("expected one issue");
862        assert_eq!(issue.error_code, Some("E021"));
863        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
864        assert_eq!(issue.element_index, Some(2));
865        assert_eq!(issue.component_index, Some(1));
866    }
867
868    #[test]
869    fn profile_pack_lenient_collects_profile_rule_issues() {
870        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
871        let segments = crate::from_bytes(input)
872            .collect::<Result<Vec<_>, _>>()
873            .expect("expected parse success");
874
875        let ctx = ValidationContext::builder()
876            .with_profile_pack(demo_orders_profile_pack())
877            .build();
878
879        let report = ctx.validate_lenient(&segments);
880        assert!(report.has_errors());
881        assert!(
882            report
883                .errors()
884                .iter()
885                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
886        );
887        assert!(
888            report
889                .warnings()
890                .iter()
891                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
892        );
893    }
894
895    #[test]
896    fn profile_pack_strict_fails_when_profile_errors_exist() {
897        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
898        let segments = crate::from_bytes(input)
899            .collect::<Result<Vec<_>, _>>()
900            .expect("expected parse success");
901
902        let ctx = ValidationContext::builder()
903            .with_profile_pack(demo_orders_profile_pack())
904            .build();
905        let result = ctx.validate_strict(&segments);
906        assert!(matches!(result, Err(EdifactError::ValidationFailed { .. })));
907    }
908}