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};
4
5/// A profile rule that can be added to a [`ProfileRulePack`].
6///
7/// Implement this trait to create reusable, composable profile rules for
8/// EDIFACT message validation.
9pub trait ProfileRule: Send + Sync {
10    /// Evaluate the rule against the given segments.
11    ///
12    /// Return `Some(issue)` if the rule is violated, or `None` if the segments pass.
13    fn evaluate(&self, segments: &[Segment<'_>]) -> Option<ValidationIssue>;
14}
15
16struct ClosureProfileRule<F>(F);
17
18impl<F> ProfileRule for ClosureProfileRule<F>
19where
20    F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync,
21{
22    fn evaluate(&self, segments: &[Segment<'_>]) -> Option<ValidationIssue> {
23        (self.0)(segments)
24    }
25}
26
27/// A profile/MIG rule pack that can be plugged into `ValidationContext`.
28pub struct ProfileRulePack {
29    name: String,
30    message_types: Vec<String>,
31    rules: Vec<Box<dyn ProfileRule + Send + Sync>>,
32}
33
34impl ProfileRulePack {
35    /// Create an empty rule pack.
36    pub fn new(name: impl Into<String>) -> Self {
37        Self {
38            name: name.into(),
39            message_types: Vec::new(),
40            rules: Vec::new(),
41        }
42    }
43
44    /// Alias for [`ProfileRulePack::new`] for ergonomic fluent-builder use.
45    ///
46    /// Because all builder methods (`for_message_type`, `with_rule_fn`, `merge`) are
47    /// consuming methods on `ProfileRulePack` itself, no separate builder type is needed:
48    ///
49    /// ```rust,ignore
50    /// let pack = ProfileRulePack::builder("MY-PACK")
51    ///     .for_message_type("ORDERS")
52    ///     .with_rule_fn(|_segs| None);
53    /// ```
54    pub fn builder(name: impl Into<String>) -> Self {
55        Self::new(name)
56    }
57
58    /// Return the pack name.
59    pub fn name(&self) -> &str {
60        &self.name
61    }
62
63    /// Return the message types this pack is scoped to.
64    pub fn message_types(&self) -> &[String] {
65        &self.message_types
66    }
67
68    /// Return the number of rules in this pack.
69    pub fn rule_count(&self) -> usize {
70        self.rules.len()
71    }
72
73    /// Restrict this pack to one or more EDIFACT message types from the `UNH` segment.
74    ///
75    /// When a pack has one or more message-type restrictions, its rules are only evaluated
76    /// against messages whose `UNH` element 1, component 0 matches one of the registered
77    /// types (e.g. `"ORDERS"`, `"INVOIC"`).
78    ///
79    /// # Silent-skip behaviour
80    ///
81    /// If the input segments do not contain a `UNH` segment, or if the `UNH` message-type
82    /// element is absent, the pack will **silently skip all rules** rather than returning an
83    /// error.  This is intentional: without a readable message type the pack cannot
84    /// determine whether its rules apply, so it errs on the side of no false positives.
85    ///
86    /// If you need a hard failure on a missing `UNH`, add a dedicated [`ProfileRule`] that
87    /// checks for the segment's presence before other rules run.
88    pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
89        let message_type = message_type.into();
90        if !self.message_types.contains(&message_type) {
91            self.message_types.push(message_type);
92        }
93        self
94    }
95
96    /// Add one externally authored rule using only public API.
97    pub fn with_rule_fn<F>(mut self, rule: F) -> Self
98    where
99        F: for<'a> Fn(&[Segment<'a>]) -> Option<ValidationIssue> + Send + Sync + 'static,
100    {
101        self.rules.push(Box::new(ClosureProfileRule(rule)));
102        self
103    }
104
105    /// Add a rule that implements [`ProfileRule`].
106    pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
107        self.rules.push(Box::new(rule));
108        self
109    }
110
111    /// Merge two packs into one combined pack.
112    pub fn merge(mut self, mut other: Self) -> Self {
113        for message_type in other.message_types.drain(..) {
114            if !self.message_types.contains(&message_type) {
115                self.message_types.push(message_type);
116            }
117        }
118        self.rules.append(&mut other.rules);
119        self
120    }
121}
122
123impl Validator for ProfileRulePack {
124    fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
125        let message_type = segments
126            .iter()
127            .find(|segment| segment.tag == "UNH")
128            .and_then(|segment| segment.get_element(1))
129            .and_then(|element| element.get_component(0));
130        if !self.message_types.is_empty()
131            && !message_type.is_some_and(|mt| self.message_types.iter().any(|t| t == mt))
132        {
133            return;
134        }
135
136        for rule in &self.rules {
137            if let Some(issue) = rule.evaluate(segments) {
138                match issue.severity {
139                    ValidationSeverity::Critical | ValidationSeverity::Error => {
140                        report.add_error(issue);
141                    }
142                    ValidationSeverity::Warning => {
143                        report.add_warning(issue);
144                    }
145                    ValidationSeverity::Info => {
146                        report.add_info(issue);
147                    }
148                }
149            }
150        }
151    }
152}
153
154impl std::fmt::Debug for ProfileRulePack {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("ProfileRulePack")
157            .field("name", &self.name)
158            .field("message_types", &self.message_types)
159            .field("rule_count", &self.rules.len())
160            .finish()
161    }
162}
163
164/// Validation layers used by [`ValidationContext`].
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum ValidationLayer {
168    /// Directory structure checks (segment presence/order/arity).
169    Structure,
170    /// Directory code-list checks.
171    CodeList,
172    /// Downstream profile-pack checks.
173    Profile,
174}
175
176struct LayeredValidator {
177    layer: ValidationLayer,
178    validator: Box<dyn Validator + Send + Sync>,
179}
180
181/// Runtime validation context for progressive layered validation.
182pub struct ValidationContext {
183    validators: Vec<LayeredValidator>,
184    structure_enabled: bool,
185    code_list_enabled: bool,
186    profile_enabled: bool,
187    message_type: Option<String>,
188}
189
190/// Builder for [`ValidationContext`].
191#[must_use = "call `.build()` to produce a `ValidationContext`"]
192pub struct ValidationContextBuilder {
193    inner: ValidationContext,
194}
195
196impl Default for ValidationContextBuilder {
197    /// Default context builder has all layers enabled, same as [`ValidationContextBuilder::new`].
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl ValidationContextBuilder {
204    /// Create a new context builder with all layers enabled.
205    pub fn new() -> Self {
206        Self {
207            inner: ValidationContext {
208                validators: Vec::new(),
209                structure_enabled: true,
210                code_list_enabled: true,
211                profile_enabled: true,
212                message_type: None,
213            },
214        }
215    }
216
217    /// Set message type metadata for downstream validators.
218    pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
219        self.inner.message_type = Some(message_type.into());
220        let configured = self.inner.message_type.as_deref();
221        for layered in &mut self.inner.validators {
222            layered.validator.set_message_type(configured);
223        }
224        self
225    }
226
227    /// Enable/disable structure validators.
228    pub fn structure(mut self, enabled: bool) -> Self {
229        self.inner.structure_enabled = enabled;
230        self
231    }
232
233    /// Enable/disable code-list validators.
234    pub fn code_list(mut self, enabled: bool) -> Self {
235        self.inner.code_list_enabled = enabled;
236        self
237    }
238
239    /// Enable/disable profile validators.
240    pub fn profile(mut self, enabled: bool) -> Self {
241        self.inner.profile_enabled = enabled;
242        self
243    }
244
245    /// Add a validator assigned to `layer`.
246    pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
247    where
248        V: Validator + 'static,
249    {
250        validator.set_message_type(self.inner.message_type.as_deref());
251        self.inner.validators.push(LayeredValidator {
252            layer,
253            validator: Box::new(validator),
254        });
255        self
256    }
257
258    /// Add a profile rule pack to the profile layer.
259    pub fn with_profile_pack(mut self, mut pack: ProfileRulePack) -> Self {
260        pack.set_message_type(self.inner.message_type.as_deref());
261        self.inner.validators.push(LayeredValidator {
262            layer: ValidationLayer::Profile,
263            validator: Box::new(pack),
264        });
265        self
266    }
267
268    /// Finalize builder and create context.
269    #[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
270    pub fn build(self) -> ValidationContext {
271        self.inner
272    }
273}
274
275impl ValidationContext {
276    /// Start building a validation context.
277    pub fn builder() -> ValidationContextBuilder {
278        ValidationContextBuilder::new()
279    }
280
281    /// Execute validators in lenient mode for enabled layers.
282    pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
283        let mut report = ValidationReport::default();
284        for lv in &self.validators {
285            if self.layer_enabled(lv.layer) {
286                lv.validator.validate_batch(segments, &mut report);
287            }
288        }
289        report
290    }
291
292    /// Execute validators in strict mode for enabled layers.
293    ///
294    /// Returns `Ok(report)` when validation produces no errors.  The returned
295    /// report may still contain warnings and infos — warnings do **not** cause
296    /// this method to return `Err`.  Call [`validate_lenient`][Self::validate_lenient]
297    /// if you want to inspect warnings without failing on errors.
298    pub fn validate_strict(
299        &self,
300        segments: &[Segment<'_>],
301    ) -> Result<ValidationReport, EdifactError> {
302        let report = self.validate_lenient(segments);
303        if report.has_errors() {
304            let first_message = report
305                .errors
306                .first()
307                .map(|e| e.message.clone())
308                .unwrap_or_else(|| "unknown validation failure".to_owned());
309            return Err(EdifactError::ValidationFailed {
310                error_count: report.errors.len(),
311                first_message,
312            });
313        }
314        Ok(report)
315    }
316
317    /// Message type metadata associated with this context, if provided.
318    pub fn message_type(&self) -> Option<&str> {
319        self.message_type.as_deref()
320    }
321
322    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
323        match layer {
324            ValidationLayer::Structure => self.structure_enabled,
325            ValidationLayer::CodeList => self.code_list_enabled,
326            ValidationLayer::Profile => self.profile_enabled,
327        }
328    }
329}
330
331/// Pluggable validator for parsed EDIFACT segments.
332///
333/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
334/// entire segment sequence and appends issues to a [`ValidationReport`].
335///
336/// For validators that work segment-by-segment, the convenience function
337/// [`validate_each`] iterates over the slice and calls a per-segment closure,
338/// so you only need to implement `validate_batch`:
339///
340/// ```rust,ignore
341/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
342///     validate_each(segments, report, |seg| {
343///         // return Ok(()) or Err(EdifactError::...)
344///         Ok(())
345///     });
346/// }
347/// ```
348pub trait Validator: Send + Sync {
349    /// Validate a full segment set and append issues to `report`.
350    fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport);
351
352    /// Configure message-type metadata for validators that support explicit scoping.
353    fn set_message_type(&mut self, _message_type: Option<&str>) {}
354}
355
356/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
357/// and converts any `Err` into report entries.
358///
359/// Use this in `validate_batch` implementations that work segment-by-segment:
360///
361/// ```rust,ignore
362/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
363///     validate_each(segments, report, |seg| { /* ... */ Ok(()) });
364/// }
365/// ```
366pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
367where
368    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
369{
370    for segment in segments {
371        if let Err(err) = f(segment) {
372            report_error(report, err);
373        }
374    }
375}
376
377/// Convert a low-level validation error to a user-facing issue and append it.
378pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
379    let issue = issue_from_error(err);
380    match issue.severity {
381        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
382        ValidationSeverity::Warning => report.add_warning(issue),
383        ValidationSeverity::Info => report.add_info(issue),
384    }
385}
386
387fn issue_from_error(err: EdifactError) -> ValidationIssue {
388    let code = err.stable_code();
389    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
390    let default_hint = err.recovery_hint();
391
392    match err {
393        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
394            issue = issue.with_segment(tag).with_offset(offset);
395        }
396        EdifactError::InvalidElementCount { tag, offset, .. } => {
397            issue = issue.with_segment(tag).with_offset(offset);
398        }
399        EdifactError::InvalidComponentCount {
400            tag,
401            element_index,
402            offset,
403            ..
404        } => {
405            issue = issue
406                .with_segment(tag)
407                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
408                .with_offset(offset);
409        }
410        EdifactError::InvalidCodeValue {
411            tag,
412            element_index,
413            offset,
414            suggestion,
415            ..
416        } => {
417            issue = issue
418                .with_segment(tag)
419                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
420                .with_offset(offset);
421            if let Some(s) = suggestion {
422                issue = issue.with_suggestion(s);
423            }
424        }
425        EdifactError::MissingSegment { tag, .. } => {
426            issue = issue.with_segment(tag);
427        }
428        EdifactError::QualifierMismatch { tag, offset, .. } => {
429            issue = issue
430                .with_segment(tag)
431                .with_element_index(0)
432                .with_offset(offset);
433        }
434        EdifactError::ConditionalRequirementNotMet {
435            tag,
436            element_index,
437            offset,
438            ..
439        } => {
440            issue = issue
441                .with_segment(tag)
442                .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
443                .with_offset(offset);
444        }
445        EdifactError::MissingRequiredElement { tag, element_index } => {
446            issue = issue.with_segment(tag);
447            if let Ok(idx) = u8::try_from(element_index) {
448                issue = issue.with_element_index(idx);
449            }
450        }
451        EdifactError::MissingRequiredComponent {
452            tag,
453            element_index,
454            component_index,
455        } => {
456            issue = issue.with_segment(tag);
457            if let Ok(ei) = u8::try_from(element_index) {
458                issue = issue.with_element_index(ei);
459            }
460            if let Ok(ci) = u8::try_from(component_index) {
461                issue = issue.with_component_index(ci);
462            }
463        }
464        EdifactError::InvalidReleaseSequence { offset }
465        | EdifactError::InvalidDelimiter { offset, .. }
466        | EdifactError::InvalidText { offset }
467        | EdifactError::UnexpectedEof { offset } => {
468            issue = issue.with_offset(offset);
469        }
470        _ => {}
471    }
472
473    if issue.suggestion.is_none() {
474        if let Some(hint) = default_hint {
475            issue = issue.with_suggestion(hint);
476        }
477    }
478
479    issue
480}
481
482fn severity_for(err: &EdifactError) -> ValidationSeverity {
483    match err {
484        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
485            ValidationSeverity::Warning
486        }
487        _ => ValidationSeverity::Error,
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::model::Element;
495
496    fn demo_orders_profile_pack() -> ProfileRulePack {
497        ProfileRulePack::builder("ORDERS-DEMO")
498            .for_message_type("ORDERS")
499            .with_rule_fn(|segments| {
500                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
501                let document_code = bgm.get_element(0)?.get_component(0)?;
502                (document_code == "220").then(|| {
503                    ValidationIssue::new(
504                        ValidationSeverity::Error,
505                        "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
506                    )
507                    .with_rule_id("DEMO-P001")
508                    .with_segment("BGM")
509                    .with_element_index(0)
510                    .with_suggestion("Use a different BGM document code in this demo pack")
511                })
512            })
513            .with_rule_fn(|segments| {
514                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
515                let reference = bgm.get_element(1)?.get_component(0)?;
516                (reference == "PO123").then(|| {
517                    ValidationIssue::new(
518                        ValidationSeverity::Warning,
519                        "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
520                    )
521                    .with_rule_id("DEMO-P002")
522                    .with_segment("BGM")
523                    .with_element_index(1)
524                    .with_suggestion("Use a non-reserved reference in this demo pack")
525                })
526            })
527    }
528
529    struct RejectBgm;
530
531    struct WarnBgm;
532
533    impl Validator for RejectBgm {
534        fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
535            validate_each(segments, report, |segment| {
536                if segment.tag == "BGM" {
537                    return Err(EdifactError::InvalidSegmentForMessage {
538                        tag: "BGM".to_owned(),
539                        message_type: "TEST".to_owned(),
540                        offset: segment.tag_span.start,
541                    });
542                }
543                Ok(())
544            });
545        }
546    }
547
548    impl Validator for WarnBgm {
549        fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
550            validate_each(segments, report, |segment| {
551                if segment.tag == "BGM" {
552                    return Err(EdifactError::InvalidCodeValue {
553                        tag: "BGM".to_owned(),
554                        element_index: 0,
555                        value: "XXX".to_owned(),
556                        code_list: "1001".to_owned(),
557                        offset: segment.span.start,
558                        suggestion: None,
559                    });
560                }
561                Ok(())
562            });
563        }
564    }
565
566    fn test_segment(tag: &'static str) -> Segment<'static> {
567        Segment {
568            tag,
569            span: crate::Span::new(0, 0),
570            tag_span: crate::Span::new(0, 0),
571            elements: vec![Element::of(&["x"])],
572        }
573    }
574
575    #[test]
576    fn lenient_collects_issues() {
577        let segments = vec![test_segment("UNH"), test_segment("BGM")];
578        let mut report = ValidationReport::default();
579        RejectBgm.validate_batch(&segments, &mut report);
580        assert!(report.has_errors());
581        assert_eq!(report.errors.len(), 1);
582    }
583
584    #[test]
585    fn strict_fails_on_errors() {
586        let segments = vec![test_segment("BGM")];
587        let mut report = ValidationReport::default();
588        RejectBgm.validate_batch(&segments, &mut report);
589        assert!(report.has_errors());
590        assert_eq!(report.errors.len(), 1);
591    }
592
593    #[test]
594    fn context_builder_respects_layer_toggles() {
595        let segments = vec![test_segment("BGM")];
596        let ctx = ValidationContext::builder()
597            .structure(false)
598            .with_validator(ValidationLayer::Structure, RejectBgm)
599            .with_validator(ValidationLayer::CodeList, WarnBgm)
600            .build();
601
602        let report = ctx.validate_lenient(&segments);
603        assert!(!report.has_errors());
604        assert_eq!(report.warnings.len(), 1);
605    }
606
607    #[test]
608    fn context_strict_fails_when_structure_enabled() {
609        let segments = vec![test_segment("BGM")];
610        let ctx = ValidationContext::builder()
611            .with_message_type("ORDERS")
612            .with_validator(ValidationLayer::Structure, RejectBgm)
613            .build();
614
615        assert_eq!(ctx.message_type(), Some("ORDERS"));
616        let result = ctx.validate_strict(&segments);
617        assert!(matches!(result, Err(EdifactError::ValidationFailed { .. })));
618    }
619
620    #[test]
621    fn report_error_applies_default_recovery_hint() {
622        let mut report = ValidationReport::default();
623        report_error(
624            &mut report,
625            EdifactError::InvalidReleaseSequence { offset: 9 },
626        );
627
628        let issue = report
629            .errors
630            .first()
631            .expect("expected one issue in the report");
632        let hint = issue
633            .suggestion
634            .as_deref()
635            .expect("expected default hint to be set");
636        assert!(hint.contains("Release character"));
637        assert_eq!(issue.error_code, Some("E019"));
638    }
639
640    #[test]
641    fn missing_required_component_maps_metadata_to_issue() {
642        let mut report = ValidationReport::default();
643        report_error(
644            &mut report,
645            EdifactError::MissingRequiredComponent {
646                tag: "BGM".to_owned(),
647                element_index: 2,
648                component_index: 1,
649            },
650        );
651
652        let issue = report
653            .errors
654            .first()
655            .expect("expected one issue");
656        assert_eq!(issue.error_code, Some("E021"));
657        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
658        assert_eq!(issue.element_index, Some(2));
659        assert_eq!(issue.component_index, Some(1));
660    }
661
662    #[test]
663    fn profile_pack_lenient_collects_profile_rule_issues() {
664        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
665        let segments = crate::from_bytes(input)
666            .collect::<Result<Vec<_>, _>>()
667            .expect("expected parse success");
668
669        let ctx = ValidationContext::builder()
670            .with_profile_pack(demo_orders_profile_pack())
671            .build();
672
673        let report = ctx.validate_lenient(&segments);
674        assert!(report.has_errors());
675        assert!(
676            report
677                .errors
678                .iter()
679                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
680        );
681        assert!(
682            report
683                .warnings
684                .iter()
685                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
686        );
687    }
688
689    #[test]
690    fn profile_pack_strict_fails_when_profile_errors_exist() {
691        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
692        let segments = crate::from_bytes(input)
693            .collect::<Result<Vec<_>, _>>()
694            .expect("expected parse success");
695
696        let ctx = ValidationContext::builder()
697            .with_profile_pack(demo_orders_profile_pack())
698            .build();
699        let result = ctx.validate_strict(&segments);
700        assert!(matches!(result, Err(EdifactError::ValidationFailed { .. })));
701    }
702}