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