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