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, 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("AHB-11001")
28///     .with_rule_fn(|segs, ctx, issues| {
29///         let Some(pruefid) = ctx.metadata::<Pruefid>() 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    /// Configure message-type metadata for validators that support explicit scoping.
147    fn set_message_type(&mut self, _message_type: Option<&str>) {}
148
149    /// Create a `Box<dyn Validator>` clone of this validator for context forking.
150    ///
151    /// The default implementation panics — only validators that are cheaply clonable
152    /// (i.e. backed by `Arc` data, like [`ProfileRulePack`]) need to override this.
153    fn fork(&self) -> Box<dyn Validator + Send + Sync> {
154        panic!(
155            "Validator::fork() not implemented for {}; \
156             only ProfileRulePack supports context forking",
157            std::any::type_name::<Self>()
158        )
159    }
160}
161
162/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
163/// and converts any `Err` into report entries.
164pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
165where
166    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
167{
168    for segment in segments {
169        if let Err(err) = f(segment) {
170            report_error(report, err);
171        }
172    }
173}
174
175/// Convert a low-level validation error to a user-facing issue and append it.
176pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
177    let issue = issue_from_error(err);
178    match issue.severity {
179        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
180        ValidationSeverity::Warning => report.add_warning(issue),
181        ValidationSeverity::Info => report.add_info(issue),
182    }
183}
184
185// ── EnvelopeValidator ─────────────────────────────────────────────────────────
186
187/// Built-in validator for EDIFACT interchange envelope structure.
188///
189/// Checks `UNB`/`UNH`/`UNT`/`UNZ` segment presence, message counts, and
190/// segment counts.  Registered by
191/// [`ValidationContextBuilder::with_envelope_validation`].
192pub struct EnvelopeValidator;
193
194impl Validator for EnvelopeValidator {
195    fn validate_batch(
196        &self,
197        segments: &[Segment<'_>],
198        report: &mut ValidationReport,
199        _ctx: &ValidationRuleContext<'_>,
200    ) {
201        if let Err(e) = crate::envelope::validate_envelope(segments) {
202            report_error(report, e);
203        }
204    }
205
206    fn fork(&self) -> Box<dyn Validator + Send + Sync> {
207        Box::new(EnvelopeValidator)
208    }
209}
210
211fn issue_from_error(err: EdifactError) -> ValidationIssue {
212    let code = err.stable_code();
213    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
214    let default_hint = err.recovery_hint();
215
216    match err {
217        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
218            issue = issue.with_segment(tag).with_offset(offset);
219        }
220        EdifactError::InvalidElementCount { tag, offset, .. } => {
221            issue = issue.with_segment(tag).with_offset(offset);
222        }
223        EdifactError::InvalidComponentCount {
224            tag,
225            element_index,
226            offset,
227            ..
228        } => {
229            issue = issue
230                .with_segment(tag)
231                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
232                .with_offset(offset);
233        }
234        EdifactError::InvalidCodeValue {
235            tag,
236            element_index,
237            offset,
238            suggestion,
239            ..
240        } => {
241            issue = issue
242                .with_segment(tag)
243                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
244                .with_offset(offset);
245            if let Some(s) = suggestion {
246                issue = issue.with_suggestion(s);
247            }
248        }
249        EdifactError::MissingSegment { tag, .. } => {
250            issue = issue.with_segment(tag);
251        }
252        EdifactError::QualifierMismatch { tag, offset, .. } => {
253            issue = issue
254                .with_segment(tag)
255                .with_element_index(0)
256                .with_offset(offset);
257        }
258        EdifactError::ConditionalRequirementNotMet {
259            tag,
260            element_index,
261            offset,
262            ..
263        } => {
264            issue = issue
265                .with_segment(tag)
266                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
267                .with_offset(offset);
268        }
269        EdifactError::MissingRequiredElement { tag, element_index } => {
270            issue = issue.with_segment(tag);
271            if let Ok(idx) = u8::try_from(element_index) {
272                issue = issue.with_element_index(idx);
273            }
274        }
275        EdifactError::MissingRequiredComponent {
276            tag,
277            element_index,
278            component_index,
279        } => {
280            issue = issue.with_segment(tag);
281            if let Ok(ei) = u8::try_from(element_index) {
282                issue = issue.with_element_index(ei);
283            }
284            if let Ok(ci) = u8::try_from(component_index) {
285                issue = issue.with_component_index(ci);
286            }
287        }
288        EdifactError::InvalidReleaseSequence { offset }
289        | EdifactError::InvalidDelimiter { offset, .. }
290        | EdifactError::InvalidText { offset }
291        | EdifactError::UnexpectedEof { offset }
292        | EdifactError::UnexpectedDataToken { offset }
293        | EdifactError::FunctionalGroupNotSupported { offset } => {
294            issue = issue.with_offset(offset);
295        }
296        _ => {}
297    }
298
299    if issue.suggestion.is_none() {
300        if let Some(hint) = default_hint {
301            issue = issue.with_suggestion(hint);
302        }
303    }
304
305    issue
306}
307
308fn severity_for(err: &EdifactError) -> ValidationSeverity {
309    match err {
310        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
311            ValidationSeverity::Warning
312        }
313        _ => ValidationSeverity::Error,
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::model::Element;
321
322    fn demo_orders_profile_pack() -> ProfileRulePack {
323        ProfileRulePack::new("ORDERS-DEMO")
324            .for_message_type("ORDERS")
325            .with_stateless_rule_fn(|segments, issues| {
326                issues.extend((|| -> Option<ValidationIssue> {
327                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
328                    let document_code = bgm.get_element(0)?.get_component(0)?;
329                    (document_code == "220").then(|| {
330                        ValidationIssue::new(
331                            ValidationSeverity::Error,
332                            "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
333                        )
334                        .with_rule_id("DEMO-P001")
335                        .with_segment("BGM")
336                        .with_element_index(0)
337                        .with_suggestion("Use a different BGM document code in this demo pack")
338                    })
339                })());
340            })
341            .with_stateless_rule_fn(|segments, issues| {
342                issues.extend((|| -> Option<ValidationIssue> {
343                    let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
344                    let reference = bgm.get_element(1)?.get_component(0)?;
345                    (reference == "PO123").then(|| {
346                        ValidationIssue::new(
347                            ValidationSeverity::Warning,
348                            "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
349                        )
350                        .with_rule_id("DEMO-P002")
351                        .with_segment("BGM")
352                        .with_element_index(1)
353                        .with_suggestion("Use a non-reserved reference in this demo pack")
354                    })
355                })());
356            })
357    }
358
359    struct RejectBgm;
360
361    struct WarnBgm;
362
363    impl Validator for RejectBgm {
364        fn validate_batch(
365            &self,
366            segments: &[Segment<'_>],
367            report: &mut ValidationReport,
368            _context: &ValidationRuleContext<'_>,
369        ) {
370            validate_each(segments, report, |segment| {
371                if segment.tag == "BGM" {
372                    return Err(EdifactError::InvalidSegmentForMessage {
373                        tag: "BGM".to_owned(),
374                        message_type: "TEST".to_owned(),
375                        offset: segment.tag_span.start,
376                    });
377                }
378                Ok(())
379            });
380        }
381    }
382
383    impl Validator for WarnBgm {
384        fn validate_batch(
385            &self,
386            segments: &[Segment<'_>],
387            report: &mut ValidationReport,
388            _context: &ValidationRuleContext<'_>,
389        ) {
390            validate_each(segments, report, |segment| {
391                if segment.tag == "BGM" {
392                    return Err(EdifactError::InvalidCodeValue {
393                        tag: "BGM".to_owned(),
394                        element_index: 0,
395                        value: "XXX".to_owned(),
396                        code_list: "1001".to_owned(),
397                        offset: segment.span.start,
398                        suggestion: None,
399                    });
400                }
401                Ok(())
402            });
403        }
404    }
405
406    fn test_segment(tag: &'static str) -> Segment<'static> {
407        Segment {
408            tag,
409            span: crate::Span::new(0, 0),
410            tag_span: crate::Span::new(0, 0),
411            elements: vec![Element::of(&["x"])],
412        }
413    }
414
415    #[test]
416    fn lenient_collects_issues() {
417        let segments = vec![test_segment("UNH"), test_segment("BGM")];
418        let mut report = ValidationReport::default();
419        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
420        assert!(report.has_errors());
421        assert_eq!(report.errors().len(), 1);
422    }
423
424    #[test]
425    fn strict_fails_on_errors() {
426        let segments = vec![test_segment("BGM")];
427        let mut report = ValidationReport::default();
428        RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
429        assert!(report.has_errors());
430        assert_eq!(report.errors().len(), 1);
431    }
432
433    #[test]
434    fn context_builder_respects_layer_toggles() {
435        let segments = vec![test_segment("BGM")];
436        let ctx = ValidationContext::builder()
437            .structure(false)
438            .with_validator(ValidationLayer::Structure, RejectBgm)
439            .with_validator(ValidationLayer::CodeList, WarnBgm)
440            .build();
441
442        let report = ctx.validate_lenient(&segments);
443        assert!(!report.has_errors());
444        assert_eq!(report.warnings().len(), 1);
445    }
446
447    #[test]
448    fn context_strict_fails_when_structure_enabled() {
449        let segments = vec![test_segment("BGM")];
450        let ctx = ValidationContext::builder()
451            .with_message_type("ORDERS")
452            .with_validator(ValidationLayer::Structure, RejectBgm)
453            .build();
454
455        assert_eq!(ctx.message_type(), Some("ORDERS"));
456        let result = ctx.validate_strict(&segments);
457        assert!(result.is_err());
458        assert!(result.unwrap_err().has_errors());
459    }
460
461    #[test]
462    fn report_error_applies_default_recovery_hint() {
463        let mut report = ValidationReport::default();
464        report_error(
465            &mut report,
466            EdifactError::InvalidReleaseSequence { offset: 9 },
467        );
468
469        let issue = report
470            .errors()
471            .first()
472            .expect("expected one issue in the report");
473        let hint = issue
474            .suggestion
475            .as_deref()
476            .expect("expected default hint to be set");
477        assert!(hint.contains("Release character"));
478        assert_eq!(issue.error_code, Some("E019"));
479    }
480
481    #[test]
482    fn missing_required_component_maps_metadata_to_issue() {
483        let mut report = ValidationReport::default();
484        report_error(
485            &mut report,
486            EdifactError::MissingRequiredComponent {
487                tag: "BGM".to_owned(),
488                element_index: 2,
489                component_index: 1,
490            },
491        );
492
493        let issue = report.errors().first().expect("expected one issue");
494        assert_eq!(issue.error_code, Some("E021"));
495        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
496        assert_eq!(issue.element_index, Some(2));
497        assert_eq!(issue.component_index, Some(1));
498    }
499
500    #[test]
501    fn profile_pack_lenient_collects_profile_rule_issues() {
502        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
503        let segments = crate::from_bytes(input)
504            .collect::<Result<Vec<_>, _>>()
505            .expect("expected parse success");
506
507        let ctx = ValidationContext::builder()
508            .with_profile_pack(demo_orders_profile_pack())
509            .build();
510
511        let report = ctx.validate_lenient(&segments);
512        assert!(report.has_errors());
513        assert!(
514            report
515                .errors()
516                .iter()
517                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
518        );
519        assert!(
520            report
521                .warnings()
522                .iter()
523                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
524        );
525    }
526
527    #[test]
528    fn profile_pack_strict_fails_when_profile_errors_exist() {
529        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
530        let segments = crate::from_bytes(input)
531            .collect::<Result<Vec<_>, _>>()
532            .expect("expected parse success");
533
534        let ctx = ValidationContext::builder()
535            .with_profile_pack(demo_orders_profile_pack())
536            .build();
537        let result = ctx.validate_strict(&segments);
538        assert!(result.is_err());
539        assert!(result.unwrap_err().has_errors());
540    }
541
542    // ── bail_on_first_error ──────────────────────────────────────────────────
543
544    /// A rule that emits two error-severity issues (one per DTM segment).
545    fn two_dtm_errors_rule() -> ProfileRulePack {
546        ProfileRulePack::new("TEST-BAIL")
547            .with_stateless_rule_fn(|segments, issues| {
548                // Rule A: emits one error per DTM segment.
549                for seg in segments.iter().filter(|s| s.tag == "DTM") {
550                    issues.push(
551                        ValidationIssue::new(
552                            ValidationSeverity::Error,
553                            format!("DTM error at offset {}", seg.span.start),
554                        )
555                        .with_rule_id("BAIL-R1")
556                        .with_segment("DTM"),
557                    );
558                }
559            })
560            .with_stateless_rule_fn(|segments, issues| {
561                // Rule B: never fires; used to verify bail skips this rule.
562                for seg in segments.iter().filter(|s| s.tag == "BGM") {
563                    issues.push(
564                        ValidationIssue::new(ValidationSeverity::Error, "BGM error")
565                            .with_rule_id("BAIL-R2")
566                            .with_segment(seg.tag),
567                    );
568                }
569            })
570    }
571
572    #[test]
573    fn bail_on_first_error_fires_at_rule_invocation_granularity() {
574        // Two DTM segments → Rule A emits 2 errors for them.
575        // With bail, Rule B (BGM check) must NOT run.
576        let input =
577            b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
578        let segments = crate::from_bytes(input)
579            .collect::<Result<Vec<_>, _>>()
580            .expect("parse failed");
581
582        let pack_with_bail = two_dtm_errors_rule().bail_on_first_error(true);
583        let ctx = ValidationContext::builder()
584            .with_profile_pack(pack_with_bail)
585            .build();
586        let report = ctx.validate_lenient(&segments);
587
588        // Rule A fires: both DTM errors are in the report (the whole rule invocation
589        // runs to completion before bail is checked).
590        assert_eq!(
591            report
592                .errors()
593                .iter()
594                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
595                .count(),
596            2,
597            "both DTM errors from Rule A should be present"
598        );
599        // Bail fired after Rule A: Rule B (BGM) must be skipped.
600        assert_eq!(
601            report
602                .errors()
603                .iter()
604                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
605                .count(),
606            0,
607            "Rule B should have been skipped by bail"
608        );
609    }
610
611    #[test]
612    fn bail_disabled_runs_all_rules() {
613        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
614        let segments = crate::from_bytes(input)
615            .collect::<Result<Vec<_>, _>>()
616            .expect("parse failed");
617
618        let pack_no_bail = two_dtm_errors_rule(); // bail_on_first_error defaults to false
619        let ctx = ValidationContext::builder()
620            .with_profile_pack(pack_no_bail)
621            .build();
622        let report = ctx.validate_lenient(&segments);
623
624        // Both rules run: one DTM error from Rule A, one BGM error from Rule B.
625        assert_eq!(
626            report
627                .errors()
628                .iter()
629                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
630                .count(),
631            1
632        );
633        assert_eq!(
634            report
635                .errors()
636                .iter()
637                .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
638                .count(),
639            1
640        );
641    }
642
643    // ── message_ref in ValidationRuleContext ─────────────────────────────────
644
645    #[test]
646    fn message_ref_is_visible_inside_rule_closure() {
647        let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
648        let segments = crate::from_bytes(input)
649            .collect::<Result<Vec<_>, _>>()
650            .expect("parse failed");
651
652        let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
653            if let Some(mref) = ctx.message_ref {
654                issues.push(
655                    ValidationIssue::new(
656                        ValidationSeverity::Info,
657                        format!("validating message {mref}"),
658                    )
659                    .with_rule_id("CTX-REF"),
660                );
661            }
662        });
663
664        let ctx = ValidationContext::builder()
665            .with_profile_pack(pack)
666            .with_message_ref("MSG001")
667            .build();
668
669        let report = ctx.validate_lenient(&segments);
670        let info = report
671            .infos()
672            .iter()
673            .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
674            .expect("expected info issue from CTX-REF rule");
675        assert!(info.message.contains("MSG001"));
676        // The message_ref is also stamped onto the issue itself.
677        assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
678    }
679}