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    pub fn validate_strict(
294        &self,
295        segments: &[Segment<'_>],
296    ) -> Result<ValidationReport, EdifactError> {
297        let report = self.validate_lenient(segments);
298        if report.has_errors() {
299            let first_message = report
300                .errors
301                .first()
302                .map(|e| e.message.clone())
303                .unwrap_or_else(|| "unknown validation failure".to_owned());
304            return Err(EdifactError::ValidationFailed {
305                error_count: report.errors.len(),
306                first_message,
307            });
308        }
309        Ok(report)
310    }
311
312    /// Message type metadata associated with this context, if provided.
313    pub fn message_type(&self) -> Option<&str> {
314        self.message_type.as_deref()
315    }
316
317    fn layer_enabled(&self, layer: ValidationLayer) -> bool {
318        match layer {
319            ValidationLayer::Structure => self.structure_enabled,
320            ValidationLayer::CodeList => self.code_list_enabled,
321            ValidationLayer::Profile => self.profile_enabled,
322        }
323    }
324}
325
326/// Pluggable validator for parsed EDIFACT segments.
327///
328/// The primary contract is [`validate_batch`](Validator::validate_batch), which processes an
329/// entire segment sequence and appends issues to a [`ValidationReport`].
330///
331/// For validators that work segment-by-segment, the convenience function
332/// [`validate_each`] iterates over the slice and calls a per-segment closure,
333/// so you only need to implement `validate_batch`:
334///
335/// ```rust,ignore
336/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
337///     validate_each(segments, report, |seg| {
338///         // return Ok(()) or Err(EdifactError::...)
339///         Ok(())
340///     });
341/// }
342/// ```
343pub trait Validator: Send + Sync {
344    /// Validate a full segment set and append issues to `report`.
345    fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport);
346
347    /// Configure message-type metadata for validators that support explicit scoping.
348    fn set_message_type(&mut self, _message_type: Option<&str>) {}
349}
350
351/// Helper for per-segment validators: iterates `segments`, calls `f` for each one,
352/// and converts any `Err` into report entries.
353///
354/// Use this in `validate_batch` implementations that work segment-by-segment:
355///
356/// ```rust,ignore
357/// fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
358///     validate_each(segments, report, |seg| { /* ... */ Ok(()) });
359/// }
360/// ```
361pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
362where
363    F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
364{
365    for segment in segments {
366        if let Err(err) = f(segment) {
367            report_error(report, err);
368        }
369    }
370}
371
372/// Convert a low-level validation error to a user-facing issue and append it.
373pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
374    let issue = issue_from_error(err);
375    match issue.severity {
376        ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
377        ValidationSeverity::Warning => report.add_warning(issue),
378        ValidationSeverity::Info => report.add_info(issue),
379    }
380}
381
382fn issue_from_error(err: EdifactError) -> ValidationIssue {
383    let code = err.stable_code();
384    let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
385    let default_hint = err.recovery_hint();
386
387    match err {
388        EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
389            issue = issue.with_segment(tag).with_offset(offset);
390        }
391        EdifactError::InvalidElementCount { tag, offset, .. } => {
392            issue = issue.with_segment(tag).with_offset(offset);
393        }
394        EdifactError::InvalidComponentCount {
395            tag,
396            element_index,
397            offset,
398            ..
399        } => {
400            issue = issue
401                .with_segment(tag)
402                .with_element_index(element_index as u8)
403                .with_offset(offset);
404        }
405        EdifactError::InvalidCodeValue {
406            tag,
407            element_index,
408            offset,
409            suggestion,
410            ..
411        } => {
412            issue = issue
413                .with_segment(tag)
414                .with_element_index(element_index as u8)
415                .with_offset(offset);
416            if let Some(s) = suggestion {
417                issue = issue.with_suggestion(s);
418            }
419        }
420        EdifactError::MissingSegment { tag, .. } => {
421            issue = issue.with_segment(tag);
422        }
423        EdifactError::QualifierMismatch { tag, offset, .. } => {
424            issue = issue
425                .with_segment(tag)
426                .with_element_index(0)
427                .with_offset(offset);
428        }
429        EdifactError::ConditionalRequirementNotMet {
430            tag,
431            element_index,
432            offset,
433            ..
434        } => {
435            issue = issue
436                .with_segment(tag)
437                .with_element_index(element_index as u8)
438                .with_offset(offset);
439        }
440        EdifactError::MissingRequiredElement { tag, element_index } => {
441            issue = issue.with_segment(tag);
442            if let Ok(idx) = u8::try_from(element_index) {
443                issue = issue.with_element_index(idx);
444            }
445        }
446        EdifactError::MissingRequiredComponent {
447            tag,
448            element_index,
449            component_index,
450        } => {
451            issue = issue.with_segment(tag);
452            if let Ok(ei) = u8::try_from(element_index) {
453                issue = issue.with_element_index(ei);
454            }
455            if let Ok(ci) = u8::try_from(component_index) {
456                issue = issue.with_component_index(ci);
457            }
458        }
459        EdifactError::InvalidReleaseSequence { offset }
460        | EdifactError::InvalidDelimiter { offset, .. }
461        | EdifactError::InvalidText { offset }
462        | EdifactError::UnexpectedEof { offset } => {
463            issue = issue.with_offset(offset);
464        }
465        _ => {}
466    }
467
468    if issue.suggestion.is_none() {
469        if let Some(hint) = default_hint {
470            issue = issue.with_suggestion(hint);
471        }
472    }
473
474    issue
475}
476
477fn severity_for(err: &EdifactError) -> ValidationSeverity {
478    match err {
479        EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
480            ValidationSeverity::Warning
481        }
482        _ => ValidationSeverity::Error,
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::model::Element;
490
491    fn demo_orders_profile_pack() -> ProfileRulePack {
492        ProfileRulePack::builder("ORDERS-DEMO")
493            .for_message_type("ORDERS")
494            .with_rule_fn(|segments| {
495                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
496                let document_code = bgm.get_element(0)?.get_component(0)?;
497                (document_code == "220").then(|| {
498                    ValidationIssue::new(
499                        ValidationSeverity::Error,
500                        "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
501                    )
502                    .with_rule_id("DEMO-P001")
503                    .with_segment("BGM")
504                    .with_element_index(0)
505                    .with_suggestion("Use a different BGM document code in this demo pack")
506                })
507            })
508            .with_rule_fn(|segments| {
509                let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
510                let reference = bgm.get_element(1)?.get_component(0)?;
511                (reference == "PO123").then(|| {
512                    ValidationIssue::new(
513                        ValidationSeverity::Warning,
514                        "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
515                    )
516                    .with_rule_id("DEMO-P002")
517                    .with_segment("BGM")
518                    .with_element_index(1)
519                    .with_suggestion("Use a non-reserved reference in this demo pack")
520                })
521            })
522    }
523
524    struct RejectBgm;
525
526    struct WarnBgm;
527
528    impl Validator for RejectBgm {
529        fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
530            validate_each(segments, report, |segment| {
531                if segment.tag == "BGM" {
532                    return Err(EdifactError::InvalidSegmentForMessage {
533                        tag: "BGM".to_owned(),
534                        message_type: "TEST".to_owned(),
535                        offset: segment.tag_span.start,
536                    });
537                }
538                Ok(())
539            });
540        }
541    }
542
543    impl Validator for WarnBgm {
544        fn validate_batch(&self, segments: &[Segment<'_>], report: &mut ValidationReport) {
545            validate_each(segments, report, |segment| {
546                if segment.tag == "BGM" {
547                    return Err(EdifactError::InvalidCodeValue {
548                        tag: "BGM".to_owned(),
549                        element_index: 0,
550                        value: "XXX".to_owned(),
551                        code_list: "1001".to_owned(),
552                        offset: segment.span.start,
553                        suggestion: None,
554                    });
555                }
556                Ok(())
557            });
558        }
559    }
560
561    fn test_segment(tag: &'static str) -> Segment<'static> {
562        Segment {
563            tag,
564            span: crate::Span::new(0, 0),
565            tag_span: crate::Span::new(0, 0),
566            elements: vec![Element::of(&["x"])],
567        }
568    }
569
570    #[test]
571    fn lenient_collects_issues() {
572        let segments = vec![test_segment("UNH"), test_segment("BGM")];
573        let mut report = ValidationReport::default();
574        RejectBgm.validate_batch(&segments, &mut report);
575        assert!(report.has_errors());
576        assert_eq!(report.errors.len(), 1);
577    }
578
579    #[test]
580    fn strict_fails_on_errors() {
581        let segments = vec![test_segment("BGM")];
582        let mut report = ValidationReport::default();
583        RejectBgm.validate_batch(&segments, &mut report);
584        assert!(report.has_errors());
585        assert_eq!(report.errors.len(), 1);
586    }
587
588    #[test]
589    fn context_builder_respects_layer_toggles() {
590        let segments = vec![test_segment("BGM")];
591        let ctx = ValidationContext::builder()
592            .structure(false)
593            .with_validator(ValidationLayer::Structure, RejectBgm)
594            .with_validator(ValidationLayer::CodeList, WarnBgm)
595            .build();
596
597        let report = ctx.validate_lenient(&segments);
598        assert!(!report.has_errors());
599        assert_eq!(report.warnings.len(), 1);
600    }
601
602    #[test]
603    fn context_strict_fails_when_structure_enabled() {
604        let segments = vec![test_segment("BGM")];
605        let ctx = ValidationContext::builder()
606            .with_message_type("ORDERS")
607            .with_validator(ValidationLayer::Structure, RejectBgm)
608            .build();
609
610        assert_eq!(ctx.message_type(), Some("ORDERS"));
611        let result = ctx.validate_strict(&segments);
612        assert!(matches!(result, Err(EdifactError::ValidationFailed { .. })));
613    }
614
615    #[test]
616    fn report_error_applies_default_recovery_hint() {
617        let mut report = ValidationReport::default();
618        report_error(
619            &mut report,
620            EdifactError::InvalidReleaseSequence { offset: 9 },
621        );
622
623        let issue = report
624            .errors
625            .first()
626            .expect("expected one issue in the report");
627        let hint = issue
628            .suggestion
629            .as_deref()
630            .expect("expected default hint to be set");
631        assert!(hint.contains("Release character"));
632        assert_eq!(issue.error_code, Some("E019"));
633    }
634
635    #[test]
636    fn missing_required_component_maps_metadata_to_issue() {
637        let mut report = ValidationReport::default();
638        report_error(
639            &mut report,
640            EdifactError::MissingRequiredComponent {
641                tag: "BGM".to_owned(),
642                element_index: 2,
643                component_index: 1,
644            },
645        );
646
647        let issue = report
648            .errors
649            .first()
650            .expect("expected one issue");
651        assert_eq!(issue.error_code, Some("E021"));
652        assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
653        assert_eq!(issue.element_index, Some(2));
654        assert_eq!(issue.component_index, Some(1));
655    }
656
657    #[test]
658    fn profile_pack_lenient_collects_profile_rule_issues() {
659        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
660        let segments = crate::from_bytes(input)
661            .collect::<Result<Vec<_>, _>>()
662            .expect("expected parse success");
663
664        let ctx = ValidationContext::builder()
665            .with_profile_pack(demo_orders_profile_pack())
666            .build();
667
668        let report = ctx.validate_lenient(&segments);
669        assert!(report.has_errors());
670        assert!(
671            report
672                .errors
673                .iter()
674                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
675        );
676        assert!(
677            report
678                .warnings
679                .iter()
680                .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
681        );
682    }
683
684    #[test]
685    fn profile_pack_strict_fails_when_profile_errors_exist() {
686        let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
687        let segments = crate::from_bytes(input)
688            .collect::<Result<Vec<_>, _>>()
689            .expect("expected parse success");
690
691        let ctx = ValidationContext::builder()
692            .with_profile_pack(demo_orders_profile_pack())
693            .build();
694        let result = ctx.validate_strict(&segments);
695        assert!(matches!(result, Err(EdifactError::ValidationFailed { .. })));
696    }
697}