Skip to main content

edifact_rs/validator/
mod.rs

1//! Validation pipeline for structural and semantic EDIFACT checks.
2//!
3//! The validation module is split into three sub-modules:
4//!
5//! - [`pack`] — [`ProfileRule`], [`ProfileRulePack`], and rule construction helpers.
6//! - [`context`] — [`ValidationContext`], [`ValidationContextBuilder`].
7//! - This module (`mod.rs`) — [`Validator`] trait, [`ValidationRuleContext`],
8//!   [`ValidationLayer`], [`EnvelopeValidator`], and helper functions.
9
10pub mod context;
11pub mod pack;
12
13pub use context::{ValidationContext, ValidationContextBuilder};
14pub use pack::{ProfileRule, ProfileRulePack};
15
16use crate::{EdifactError, Segment, Span, ValidationIssue, ValidationReport, ValidationSeverity};
17use std::any::Any;
18
19/// Typed context injected into profile rule closures at validation time.
20///
21/// Rules access per-call metadata via [`ValidationRuleContext::metadata`] and
22/// the message reference (UNH element 0) via [`ValidationRuleContext::message_ref`].
23///
24/// # Example
25///
26/// ```rust,ignore
27/// let pack = ProfileRulePack::new("PROFILE-4711")
28///     .with_rule_fn(|segs, ctx, issues| {
29///         let Some(process_id) = ctx.metadata::<ProcessId>() else { return };
30///         let msg_ref = ctx.message_ref.unwrap_or("<unknown>");
31///     });
32/// ```
33#[derive(Clone, Copy)]
34pub struct ValidationRuleContext<'a> {
35    pub(super) metadata: Option<&'a (dyn Any + Send + Sync)>,
36    /// Message reference (`UNH` element 0) for this validation call.
37    pub message_ref: Option<&'a str>,
38    /// EDIFACT message type extracted from `UNH` element 1 component 0.
39    ///
40    /// Pre-extracted once by [`ValidationContext`] before dispatching to validators,
41    /// so each [`ProfileRulePack`] can skip its own `UNH` scan.
42    /// `None` when the message type could not be determined (no `UNH`, or when calling
43    /// [`ProfileRulePack::validate_batch`] directly without a `ValidationContext`).
44    pub message_type: Option<&'a str>,
45}
46
47impl<'a> ValidationRuleContext<'a> {
48    /// Construct a context with no metadata and no message reference.
49    pub fn empty() -> Self {
50        Self {
51            metadata: None,
52            message_ref: None,
53            message_type: None,
54        }
55    }
56
57    /// Construct a context holding a typed metadata reference.
58    pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
59        Self {
60            metadata: Some(value as &(dyn Any + Send + Sync)),
61            message_ref: None,
62            message_type: None,
63        }
64    }
65
66    /// Attach a message reference to this context (builder-style).
67    pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
68        self.message_ref = Some(msg_ref);
69        self
70    }
71
72    /// Attach a pre-extracted message type to this context (builder-style).
73    pub fn with_message_type(mut self, message_type: &'a str) -> Self {
74        self.message_type = Some(message_type);
75        self
76    }
77
78    /// Downcast the metadata to `T`.  Returns `None` if no metadata was
79    /// injected or if the concrete type does not match `T`.
80    pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
81        self.metadata?.downcast_ref::<T>()
82    }
83
84    /// Return `true` if metadata was provided.
85    pub fn has_metadata(&self) -> bool {
86        self.metadata.is_some()
87    }
88}
89
90impl std::fmt::Debug for ValidationRuleContext<'_> {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("ValidationRuleContext")
93            .field("has_metadata", &self.metadata.is_some())
94            .field("message_ref", &self.message_ref)
95            .field("message_type", &self.message_type)
96            .finish()
97    }
98}
99
100/// Validation layers used by [`ValidationContext`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum ValidationLayer {
104    /// Interchange / message envelope checks (`UNB`/`UNH`/`UNT`/`UNZ` counts).
105    Envelope,
106    /// Directory structure checks (segment presence/order/arity).
107    Structure,
108    /// Directory code-list checks.
109    CodeList,
110    /// Downstream profile-pack checks.
111    Profile,
112}
113
114/// Pluggable validator for parsed EDIFACT segments.
115///
116/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
117/// entire segment sequence and appends issues to a [`ValidationReport`].
118pub trait Validator: Send + Sync {
119    /// Validate a full segment set and append issues to `report`.
120    fn validate_batch(
121        &self,
122        segments: &[Segment<'_>],
123        report: &mut ValidationReport,
124        context: &ValidationRuleContext<'_>,
125    );
126
127    /// Validate a segment-group tree and append issues to `report`.
128    ///
129    /// Called by [`ValidationContext::validate_lenient_grouped`] in addition to
130    /// [`validate_batch`](Validator::validate_batch).  Validators that only perform
131    /// flat segment checks (e.g. [`EnvelopeValidator`]) can leave this as the
132    /// default no-op; only validators with group-scoped rules (typically
133    /// [`ProfileRulePack`] with at least one group rule) need to override it.
134    ///
135    /// The default implementation is a no-op so that adding this method to the
136    /// trait is not a breaking change for external `Validator` implementors.
137    fn validate_group_batch(
138        &self,
139        _root: &crate::group::SegmentGroupIndexed<'_>,
140        _all_segments: &[Segment<'_>],
141        _report: &mut ValidationReport,
142        _context: &ValidationRuleContext<'_>,
143    ) {
144    }
145
146    /// Returns `true` if this validator has any group-scoped rules.
147    ///
148    /// Used by [`crate::ValidationContext`] to short-circuit the
149    /// group-tree walk when no validator in the context has group rules,
150    /// avoiding the cost of allocating a borrowed slice for nothing.
151    ///
152    /// The default implementation returns `false`.
153    fn has_group_rules(&self) -> bool {
154        false
155    }
156
157    /// Configure message-type metadata for validators that support explicit scoping.
158    fn set_message_type(&mut self, _message_type: Option<&str>) {}
159
160    /// Create a `Box<dyn Validator>` clone of this validator for context forking.
161    ///
162    /// Return `Some(boxed_clone)` for validators that support cheap forking
163    /// (e.g. those backed by `Arc` data, like [`ProfileRulePack`]).
164    ///
165    /// Return `None` for validators that cannot be forked (e.g. stateful validators
166    /// without `Clone`).  Returning `None` causes the validator to be silently
167    /// **excluded** from forked contexts — forking is used by
168    /// [`crate::ValidationContext::validate_lenient_grouped`] to validate
169    /// each group in isolation, so omitting a non-forkable validator from the
170    /// forked context is safer than panicking.
171    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
172        None
173    }
174}
175
176/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
177/// and converts any `Err` into report entries.
178pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
179where
180    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
181{
182    for segment in segments {
183        if let Err(err) = f(segment) {
184            report_error(report, err);
185        }
186    }
187}
188
189/// Convert a low-level validation error to a user-facing issue and append it.
190///
191/// Severity comes from [`report::severity_for_error`][crate::report::severity_for_error],
192/// which is also what the `miette` rendering uses.
193pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
194    let issue = issue_from_error(err);
195    match issue.severity {
196        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
197        ValidationSeverity::Warning => report.add_warning(issue),
198        ValidationSeverity::Info => report.add_info(issue),
199    }
200}
201
202// ── EnvelopeValidator ─────────────────────────────────────────────────────────
203
204/// Built-in validator for EDIFACT interchange envelope structure.
205///
206/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
207/// segment counts.  Registered by
208/// [`ValidationContextBuilder::with_envelope_validation`].
209pub struct EnvelopeValidator;
210
211impl Validator for EnvelopeValidator {
212    fn validate_batch(
213        &self,
214        segments: &[Segment<'_>],
215        report: &mut ValidationReport,
216        _ctx: &ValidationRuleContext<'_>,
217    ) {
218        // Use the lenient path so a single report surfaces every envelope
219        // violation.  The strict path stops at the first, which made
220        // `ValidationContext::validate_lenient` — whose whole purpose is
221        // exhaustive reporting — yield at most one envelope issue per batch.
222        for e in crate::envelope::validate_envelope_lenient(segments).errors {
223            report_error(report, e);
224        }
225    }
226
227    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
228        Some(Box::new(EnvelopeValidator))
229    }
230}
231
232// ── SyntaxValidator ───────────────────────────────────────────────────────────
233
234/// Checks the ISO 9735-1 rules that hold for **every** interchange, whatever
235/// directory or profile it claims.
236///
237/// These are the rules a partner's translator enforces before it ever looks at a
238/// message type, and they need no directory to check:
239///
240/// | Rule | Source | Reported as |
241/// |---|---|---|
242/// | A segment carries at least one data element besides its tag | §7.5, §8.5 | `E046` |
243/// | No data element value is made only of spaces | §9.3 | `E045` (warning) |
244///
245/// Both are things a hand-rolled writer produces by accident: an `ABC'` where a
246/// conditional segment should have been dropped entirely, or a fixed-width field
247/// padded with spaces instead of suppressed. Neither trips a count check, and
248/// both are rejected downstream.
249///
250/// Registered by
251/// [`ValidationContextBuilder::with_syntax_validation`][crate::ValidationContextBuilder::with_syntax_validation].
252///
253/// # Example
254///
255/// ```
256/// use edifact_rs::{from_bytes, ValidationContext};
257///
258/// // `FTX+` carries a value of nothing but spaces; `DTM` carries no data at all.
259/// let segments: Vec<_> = from_bytes(b"FTX+   'DTM'")
260///     .collect::<Result<_, _>>()?;
261///
262/// let report = ValidationContext::builder()
263///     .with_syntax_validation()
264///     .build()
265///     .validate_lenient(&segments);
266///
267/// assert_eq!(report.errors()[0].error_code(), Some("E046")); // DTM has no data element
268/// assert_eq!(report.warnings()[0].error_code(), Some("E045")); // FTX value is only spaces
269/// # Ok::<(), edifact_rs::EdifactError>(())
270/// ```
271pub struct SyntaxValidator;
272
273impl Validator for SyntaxValidator {
274    fn validate_batch(
275        &self,
276        segments: &[Segment<'_>],
277        report: &mut ValidationReport,
278        _context: &ValidationRuleContext<'_>,
279    ) {
280        for segment in segments {
281            // §7.5: "A segment shall contain at least one data element in
282            // addition to the segment tag."  A segment whose only element is
283            // empty still satisfies this — §8.4 spells out `ABC'` versus the
284            // mandatory-segment-with-no-data form — so the test is on presence,
285            // not on content.
286            if segment.elements.is_empty() {
287                report_error(
288                    report,
289                    EdifactError::SegmentWithoutDataElements {
290                        tag: segment.tag.to_owned(),
291                        span: segment.span,
292                    },
293                );
294            }
295
296            // §8.7.1: separators that would follow omitted data elements at the
297            // end of a segment "shall also be omitted".  A segment ending in an
298            // empty element carries a separator that says nothing.
299            if segment
300                .elements
301                .last()
302                .is_some_and(|element| element.repetitions().flatten().all(|(v, _)| v.is_empty()))
303                && segment.elements.len() > 1
304            {
305                report_error(
306                    report,
307                    EdifactError::TrailingSeparator {
308                        tag: segment.tag.to_owned(),
309                        element_index: None,
310                        span: segment.span,
311                    },
312                );
313            }
314
315            for (element_index, element) in segment.elements.iter().enumerate() {
316                // §8.7.2 says the same for components at the end of a composite.
317                // Only a composite can have one: a single-component element that
318                // is empty is an omitted element, not a trailing separator.
319                if element.components.len() > 1
320                    && element.components.last().is_some_and(|(v, _)| v.is_empty())
321                {
322                    report_error(
323                        report,
324                        EdifactError::TrailingSeparator {
325                            tag: segment.tag.to_owned(),
326                            element_index: Some(element_index),
327                            span: element.span,
328                        },
329                    );
330                }
331
332                for components in element.repetitions() {
333                    for (component_index, (value, span)) in components.iter().enumerate() {
334                        // §9.3: "A data element value containing only space(s)
335                        // shall not be allowed."  An empty value is a different
336                        // thing entirely — that is how EDIFACT spells "absent".
337                        if !value.is_empty() && value.bytes().all(|b| b == b' ') {
338                            report_error(
339                                report,
340                                EdifactError::BlankDataElementValue {
341                                    tag: segment.tag.to_owned(),
342                                    element_index,
343                                    component_index,
344                                    span: *span,
345                                },
346                            );
347                        }
348                    }
349                }
350            }
351        }
352    }
353
354    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
355        Some(Box::new(SyntaxValidator))
356    }
357}
358
359// ── CharsetValidator ──────────────────────────────────────────────────────────
360
361/// Checks that every value in the message is expressible in the interchange's
362/// declared character repertoire (`UNB` S001 DE 0001).
363///
364/// This is the check partners actually enforce and that costs real money when it
365/// is missed: a `UNOA` interchange carrying a lower-case letter, or a `UNOC` one
366/// carrying `€`, is rejected at the far end — but only after it has been sent.
367///
368/// Values are checked as **decoded text**, so run this after
369/// [`decode_interchange`][crate::decode_interchange] (or on natively-ASCII
370/// input). It answers "can this text be written back out in the repertoire the
371/// header promises?", which is the question that matters.
372///
373/// Registered by
374/// [`ValidationContextBuilder::with_charset_validation`][crate::ValidationContextBuilder::with_charset_validation].
375///
376/// # Example
377///
378/// ```
379/// use edifact_rs::{from_bytes, ValidationContext};
380///
381/// // UNOA is upper-case only, but the party name is mixed case.
382/// let segments: Vec<_> = from_bytes(
383///     b"UNB+UNOA:3+S+R+200101:0900+1'NAD+BY+Acme Ltd'UNZ+0+1'",
384/// )
385/// .collect::<Result<_, _>>()?;
386///
387/// let report = ValidationContext::builder()
388///     .with_charset_validation()
389///     .build()
390///     .validate_lenient(&segments);
391///
392/// let issue = report.errors().iter().find(|i| i.error_code() == Some("E038")).unwrap();
393/// assert_eq!(issue.segment_tag.as_deref(), Some("NAD"));
394/// # Ok::<(), edifact_rs::EdifactError>(())
395/// ```
396pub struct CharsetValidator {
397    /// Repertoire to check against; `None` reads it from the interchange's `UNB`.
398    charset: Option<crate::Charset>,
399}
400
401impl CharsetValidator {
402    /// Read the repertoire from the interchange's own `UNB` S001 DE 0001.
403    ///
404    /// A slice with no `UNB` — a single message window, say — is not checked,
405    /// because nothing declares what it should be checked against.
406    #[must_use]
407    pub fn from_envelope() -> Self {
408        Self { charset: None }
409    }
410
411    /// Check against a fixed repertoire, whatever the `UNB` says.
412    ///
413    /// Use this for message-level slices that carry no `UNB`, or to hold a
414    /// partner to a stricter repertoire than the one they declare.
415    #[must_use]
416    pub fn with_charset(charset: crate::Charset) -> Self {
417        Self {
418            charset: Some(charset),
419        }
420    }
421
422    /// The repertoire in force for `segments`.
423    fn resolve(&self, segments: &[Segment<'_>]) -> Option<crate::Charset> {
424        if self.charset.is_some() {
425            return self.charset;
426        }
427        let identifier = segments
428            .iter()
429            .find(|s| s.tag == "UNB")
430            .and_then(|unb| unb.component_str(0, 0))?;
431        // An unrecognised or unsupported identifier is the envelope validator's
432        // finding to report, not this one's — silently declining to check is the
433        // right move rather than raising a second, confusing error for it.
434        crate::Charset::from_syntax_identifier(identifier).ok()
435    }
436}
437
438impl Validator for CharsetValidator {
439    fn validate_batch(
440        &self,
441        segments: &[Segment<'_>],
442        report: &mut ValidationReport,
443        _context: &ValidationRuleContext<'_>,
444    ) {
445        let Some(charset) = self.resolve(segments) else {
446            return;
447        };
448        if charset == crate::Charset::UnoY {
449            return; // Everything is permitted; nothing to check.
450        }
451        for segment in segments {
452            for (element_index, element) in segment.elements.iter().enumerate() {
453                for components in element.repetitions() {
454                    for (component_index, (value, span)) in components.iter().enumerate() {
455                        let Some((offset, character)) = charset.first_violation(value) else {
456                            continue;
457                        };
458                        let mut issue = ValidationIssue::new(
459                            ValidationSeverity::Error,
460                            format!(
461                                "character {character:?} is not in the {charset} character \
462                                 repertoire declared by UNB S001",
463                            ),
464                        )
465                        .with_error_code(
466                            EdifactError::CharacterNotInRepertoire {
467                                charset: charset.syntax_identifier(),
468                                character,
469                                offset,
470                            }
471                            .stable_code(),
472                        )
473                        .with_segment(segment.tag)
474                        .with_span(*span)
475                        .with_suggestion(
476                            "Transliterate the value, or declare a wider repertoire in UNB S001 \
477                             (UNOC for Latin-1, UNOY for UTF-8)",
478                        );
479                        if let Ok(index) = u8::try_from(element_index) {
480                            issue = issue.with_element_index(index);
481                        }
482                        if let Ok(index) = u8::try_from(component_index) {
483                            issue = issue.with_component_index(index);
484                        }
485                        report.add_error(issue);
486                    }
487                }
488            }
489        }
490    }
491
492    fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
493        Some(Box::new(Self {
494            charset: self.charset,
495        }))
496    }
497}
498
499fn issue_from_error(err: EdifactError) -> ValidationIssue {
500    let code = err.stable_code();
501    let mut issue = ValidationIssue::new(crate::report::severity_for_error(&err), err.to_string())
502        .with_error_code(code);
503    let default_hint = err.recovery_hint();
504
505    match err {
506        EdifactError::InvalidSegmentForMessage { tag, span, .. } => {
507            issue = issue.with_segment(tag).with_span(span);
508        }
509        EdifactError::InvalidElementCount { tag, span, .. } => {
510            issue = issue.with_segment(tag).with_span(span);
511        }
512        EdifactError::InvalidComponentCount {
513            tag,
514            element_index,
515            span,
516            ..
517        } => {
518            issue = issue
519                .with_segment(tag)
520                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
521                .with_span(span);
522        }
523        EdifactError::InvalidCodeValue {
524            tag,
525            element_index,
526            span,
527            suggestion,
528            ..
529        } => {
530            issue = issue
531                .with_segment(tag)
532                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
533                .with_span(span);
534            if let Some(s) = suggestion {
535                issue = issue.with_suggestion(s);
536            }
537        }
538        EdifactError::MissingSegment { tag, .. } => {
539            issue = issue.with_segment(tag);
540        }
541        EdifactError::QualifierMismatch { tag, span, .. } => {
542            issue = issue
543                .with_segment(tag)
544                .with_element_index(0)
545                .with_span(span);
546        }
547        EdifactError::ConditionalRequirementNotMet {
548            tag,
549            element_index,
550            span,
551            ..
552        } => {
553            issue = issue
554                .with_segment(tag)
555                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
556                .with_span(span);
557        }
558        EdifactError::DuplicateReference { tag, span, .. }
559        | EdifactError::PackageNotSupported { tag, span }
560        | EdifactError::SegmentWithoutDataElements { tag, span } => {
561            issue = issue.with_segment(tag).with_span(span);
562        }
563        EdifactError::EmptyMessage { span, .. } => {
564            issue = issue.with_segment("UNH").with_span(span);
565        }
566        EdifactError::GroupsAndMessagesMixed { span } => {
567            issue = issue.with_segment("UNH").with_span(span);
568        }
569        EdifactError::TrailingSeparator {
570            tag,
571            element_index,
572            span,
573        } => {
574            issue = issue.with_segment(tag).with_span(span);
575            if let Some(index) = element_index.and_then(|i| u8::try_from(i).ok()) {
576                issue = issue.with_element_index(index);
577            }
578        }
579        // The trailer is what got the count wrong, and carrying its span is what
580        // places the finding on that message rather than on the interchange.
581        EdifactError::SegmentCountMismatch {
582            span, message_ref, ..
583        } => {
584            issue = issue
585                .with_segment("UNT")
586                .with_span(span)
587                .with_message_ref(message_ref);
588        }
589        EdifactError::TooManyRepetitions {
590            tag,
591            element_index,
592            span,
593            ..
594        } => {
595            issue = issue.with_segment(tag).with_span(span);
596            if let Ok(index) = u8::try_from(element_index) {
597                issue = issue.with_element_index(index);
598            }
599        }
600        EdifactError::InvalidCharacterType {
601            tag,
602            element_index,
603            component_index,
604            span,
605            ..
606        }
607        | EdifactError::DataElementTooLong {
608            tag,
609            element_index,
610            component_index,
611            span,
612            ..
613        }
614        | EdifactError::DataElementTooShort {
615            tag,
616            element_index,
617            component_index,
618            span,
619            ..
620        }
621        | EdifactError::InsignificantCharacters {
622            tag,
623            element_index,
624            component_index,
625            span,
626            ..
627        }
628        | EdifactError::BlankDataElementValue {
629            tag,
630            element_index,
631            component_index,
632            span,
633        } => {
634            issue = issue.with_segment(tag).with_span(span);
635            if let Ok(index) = u8::try_from(element_index) {
636                issue = issue.with_element_index(index);
637            }
638            if let Ok(index) = u8::try_from(component_index) {
639                issue = issue.with_component_index(index);
640            }
641        }
642        EdifactError::MissingRequiredElement { tag, element_index } => {
643            issue = issue.with_segment(tag);
644            if let Ok(idx) = u8::try_from(element_index) {
645                issue = issue.with_element_index(idx);
646            }
647        }
648        EdifactError::MissingRequiredComponent {
649            tag,
650            element_index,
651            component_index,
652        } => {
653            issue = issue.with_segment(tag);
654            if let Ok(ei) = u8::try_from(element_index) {
655                issue = issue.with_element_index(ei);
656            }
657            if let Ok(ci) = u8::try_from(component_index) {
658                issue = issue.with_component_index(ci);
659            }
660        }
661        // Lexical faults are a point in the byte stream, not a range: record a
662        // zero-width span so `span` stays the single positional field.
663        EdifactError::InvalidReleaseSequence { offset }
664        | EdifactError::InvalidDelimiter { offset, .. }
665        | EdifactError::InvalidText { offset }
666        | EdifactError::UnexpectedEof { offset }
667        | EdifactError::UnexpectedDataToken { offset }
668        | EdifactError::SegmentTooLong { offset, .. } => {
669            issue = issue.with_span(Span::new(offset, offset));
670        }
671        _ => {}
672    }
673
674    if issue.suggestion.is_none() {
675        if let Some(hint) = default_hint {
676            issue = issue.with_suggestion(hint);
677        }
678    }
679
680    issue
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::model::Element;
687
688    fn demo_orders_profile_pack() -> ProfileRulePack {
689        ProfileRulePack::new("ORDERS-DEMO")
690            .for_message_type("ORDERS")
691            .with_stateless_rule_fn(|segments, issues| {
692                issues.extend((|| -> Option<ValidationIssue> {
693                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
694                    let document_code = bgm.get_element(0)?.get_component(0)?;
695                    (document_code == "220").then(|| {
696                        ValidationIssue::new(
697                            ValidationSeverity::Error,
698                            "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
699                        )
700                        .with_rule_id("DEMO-P001")
701                        .with_segment("BGM")
702                        .with_element_index(0)
703                        .with_suggestion("Use a different BGM document code in this demo pack")
704                    })
705                })());
706            })
707            .with_stateless_rule_fn(|segments, issues| {
708                issues.extend((|| -> Option<ValidationIssue> {
709                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
710                    let reference = bgm.get_element(1)?.get_component(0)?;
711                    (reference == "PO123").then(|| {
712                        ValidationIssue::new(
713                            ValidationSeverity::Warning,
714                            "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
715                        )
716                        .with_rule_id("DEMO-P002")
717                        .with_segment("BGM")
718                        .with_element_index(1)
719                        .with_suggestion("Use a non-reserved reference in this demo pack")
720                    })
721                })());
722            })
723    }
724
725    struct RejectBgm;
726
727    struct WarnBgm;
728
729    impl Validator for RejectBgm {
730        fn validate_batch(
731            &self,
732            segments: &[Segment<'_>],
733            report: &mut ValidationReport,
734            _context: &ValidationRuleContext<'_>,
735        ) {
736            validate_each(segments, report, |segment| {
737                if segment.tag == "BGM" {
738                    return Err(EdifactError::InvalidSegmentForMessage {
739                        tag: "BGM".to_owned(),
740                        message_type: "TEST".to_owned(),
741                        span: segment.tag_span,
742                    });
743                }
744                Ok(())
745            });
746        }
747    }
748
749    impl Validator for WarnBgm {
750        fn validate_batch(
751            &self,
752            segments: &[Segment<'_>],
753            report: &mut ValidationReport,
754            _context: &ValidationRuleContext<'_>,
755        ) {
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                        span: segment.span,
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!(result.is_err());
824        assert!(result.unwrap_err().has_errors());
825    }
826
827    #[test]
828    fn report_error_applies_default_recovery_hint() {
829        let mut report = ValidationReport::default();
830        report_error(
831            &mut report,
832            EdifactError::InvalidReleaseSequence { offset: 9 },
833        );
834
835        let issue = report
836            .errors()
837            .first()
838            .expect("expected one issue in the report");
839        let hint = issue
840            .suggestion
841            .as_deref()
842            .expect("expected default hint to be set");
843        assert!(hint.contains("Release character"));
844        assert_eq!(issue.error_code(), Some("E019"));
845    }
846
847    #[test]
848    fn missing_required_component_maps_metadata_to_issue() {
849        let mut report = ValidationReport::default();
850        report_error(
851            &mut report,
852            EdifactError::MissingRequiredComponent {
853                tag: "BGM".to_owned(),
854                element_index: 2,
855                component_index: 1,
856            },
857        );
858
859        let issue = report.errors().first().expect("expected one issue");
860        assert_eq!(issue.error_code(), Some("E021"));
861        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
862        assert_eq!(issue.element_index, Some(2));
863        assert_eq!(issue.component_index, Some(1));
864    }
865
866    #[test]
867    fn profile_pack_lenient_collects_profile_rule_issues() {
868        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
869        let segments = crate::from_bytes(input)
870            .collect::<Result<Vec<_>, _>>()
871            .expect("expected parse success");
872
873        let ctx = ValidationContext::builder()
874            .with_profile_pack(demo_orders_profile_pack())
875            .build();
876
877        let report = ctx.validate_lenient(&segments);
878        assert!(report.has_errors());
879        assert!(
880            report
881                .errors()
882                .iter()
883                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
884        );
885        assert!(
886            report
887                .warnings()
888                .iter()
889                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
890        );
891    }
892
893    #[test]
894    fn profile_pack_strict_fails_when_profile_errors_exist() {
895        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
896        let segments = crate::from_bytes(input)
897            .collect::<Result<Vec<_>, _>>()
898            .expect("expected parse success");
899
900        let ctx = ValidationContext::builder()
901            .with_profile_pack(demo_orders_profile_pack())
902            .build();
903        let result = ctx.validate_strict(&segments);
904        assert!(result.is_err());
905        assert!(result.unwrap_err().has_errors());
906    }
907
908    // ── bail_on_first_error ──────────────────────────────────────────────────
909
910    /// A rule that emits two error-severity issues (one per DTM segment).
911    fn two_dtm_errors_rule() -> ProfileRulePack {
912        ProfileRulePack::new("TEST-BAIL")
913            .with_stateless_rule_fn(|segments, issues| {
914                // Rule A: emits one error per DTM segment.
915                for seg in segments.iter().filter(|s| s.tag == "DTM") {
916                    issues.push(
917                        ValidationIssue::new(
918                            ValidationSeverity::Error,
919                            format!("DTM error at offset {}", seg.span.start),
920                        )
921                        .with_rule_id("BAIL-R1")
922                        .with_segment("DTM"),
923                    );
924                }
925            })
926            .with_stateless_rule_fn(|segments, issues| {
927                // Rule B: never fires; used to verify bail skips this rule.
928                for seg in segments.iter().filter(|s| s.tag == "BGM") {
929                    issues.push(
930                        ValidationIssue::new(ValidationSeverity::Error, "BGM error")
931                            .with_rule_id("BAIL-R2")
932                            .with_segment(seg.tag),
933                    );
934                }
935            })
936    }
937
938    #[test]
939    fn bail_on_first_error_fires_at_rule_invocation_granularity() {
940        // Two DTM segments → Rule A emits 2 errors for them.
941        // With bail, Rule B (BGM check) must NOT run.
942        let input =
943            b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
944        let segments = crate::from_bytes(input)
945            .collect::<Result<Vec<_>, _>>()
946            .expect("parse failed");
947
948        let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
949        let ctx = ValidationContext::builder()
950            .with_profile_pack(pack_with_bail)
951            .build();
952        let report = ctx.validate_lenient(&segments);
953
954        // Rule A fires: both DTM errors are in the report (the whole rule invocation
955        // runs to completion before bail is checked).
956        assert_eq!(
957            report
958                .errors()
959                .iter()
960                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
961                .count(),
962            2,
963            "both DTM errors from Rule A should be present"
964        );
965        // Bail fired after Rule A: Rule B (BGM) must be skipped.
966        assert_eq!(
967            report
968                .errors()
969                .iter()
970                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
971                .count(),
972            0,
973            "Rule B should have been skipped by bail"
974        );
975    }
976
977    #[test]
978    fn bail_disabled_runs_all_rules() {
979        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
980        let segments = crate::from_bytes(input)
981            .collect::<Result<Vec<_>, _>>()
982            .expect("parse failed");
983
984        let pack_no_bail = two_dtm_errors_rule(); // bail_on_first_error defaults to false
985        let ctx = ValidationContext::builder()
986            .with_profile_pack(pack_no_bail)
987            .build();
988        let report = ctx.validate_lenient(&segments);
989
990        // Both rules run: one DTM error from Rule A, one BGM error from Rule B.
991        assert_eq!(
992            report
993                .errors()
994                .iter()
995                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
996                .count(),
997            1
998        );
999        assert_eq!(
1000            report
1001                .errors()
1002                .iter()
1003                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
1004                .count(),
1005            1
1006        );
1007    }
1008
1009    // ── message_ref in ValidationRuleContext ─────────────────────────────────
1010
1011    #[test]
1012    fn message_ref_is_visible_inside_rule_closure() {
1013        let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
1014        let segments = crate::from_bytes(input)
1015            .collect::<Result<Vec<_>, _>>()
1016            .expect("parse failed");
1017
1018        let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
1019            if let Some(mref) = ctx.message_ref {
1020                issues.push(
1021                    ValidationIssue::new(
1022                        ValidationSeverity::Info,
1023                        format!("validating message {mref}"),
1024                    )
1025                    .with_rule_id("CTX-REF"),
1026                );
1027            }
1028        });
1029
1030        let ctx = ValidationContext::builder()
1031            .with_profile_pack(pack)
1032            .with_message_ref("MSG001")
1033            .build();
1034
1035        let report = ctx.validate_lenient(&segments);
1036        let info = report
1037            .infos()
1038            .iter()
1039            .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
1040            .expect("expected info issue from CTX-REF rule");
1041        assert!(info.message.contains("MSG001"));
1042        // The message_ref is also stamped onto the issue itself.
1043        assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
1044    }
1045}