1pub mod context;
11pub mod pack;
12
13pub use context::{ValidationContext, ValidationContextBuilder};
14pub use pack::{ProfileRule, ProfileRulePack};
15
16use crate::{EdifactError, Segment, Span, ValidationIssue, ValidationReport, ValidationSeverity};
17use std::any::Any;
18
19#[derive(Clone, Copy)]
34pub struct ValidationRuleContext<'a> {
35 pub(super) metadata: Option<&'a (dyn Any + Send + Sync)>,
36 pub message_ref: Option<&'a str>,
38 pub message_type: Option<&'a str>,
45}
46
47impl<'a> ValidationRuleContext<'a> {
48 pub fn empty() -> Self {
50 Self {
51 metadata: None,
52 message_ref: None,
53 message_type: None,
54 }
55 }
56
57 pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
59 Self {
60 metadata: Some(value as &(dyn Any + Send + Sync)),
61 message_ref: None,
62 message_type: None,
63 }
64 }
65
66 pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
68 self.message_ref = Some(msg_ref);
69 self
70 }
71
72 pub fn with_message_type(mut self, message_type: &'a str) -> Self {
74 self.message_type = Some(message_type);
75 self
76 }
77
78 pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
81 self.metadata?.downcast_ref::<T>()
82 }
83
84 pub fn has_metadata(&self) -> bool {
86 self.metadata.is_some()
87 }
88}
89
90impl std::fmt::Debug for ValidationRuleContext<'_> {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("ValidationRuleContext")
93 .field("has_metadata", &self.metadata.is_some())
94 .field("message_ref", &self.message_ref)
95 .field("message_type", &self.message_type)
96 .finish()
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum ValidationLayer {
104 Envelope,
106 Structure,
108 CodeList,
110 Profile,
112}
113
114pub trait Validator: Send + Sync {
119 fn validate_batch(
121 &self,
122 segments: &[Segment<'_>],
123 report: &mut ValidationReport,
124 context: &ValidationRuleContext<'_>,
125 );
126
127 fn validate_group_batch(
138 &self,
139 _root: &crate::group::SegmentGroupIndexed<'_>,
140 _all_segments: &[Segment<'_>],
141 _report: &mut ValidationReport,
142 _context: &ValidationRuleContext<'_>,
143 ) {
144 }
145
146 fn has_group_rules(&self) -> bool {
154 false
155 }
156
157 fn set_message_type(&mut self, _message_type: Option<&str>) {}
159
160 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
172 None
173 }
174}
175
176pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
179where
180 F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
181{
182 for segment in segments {
183 if let Err(err) = f(segment) {
184 report_error(report, err);
185 }
186 }
187}
188
189pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
205 let issue = issue_from_error(err);
206 match issue.severity {
207 ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
208 ValidationSeverity::Warning => report.add_warning(issue),
209 ValidationSeverity::Info => report.add_info(issue),
210 }
211}
212
213pub struct EnvelopeValidator;
221
222impl Validator for EnvelopeValidator {
223 fn validate_batch(
224 &self,
225 segments: &[Segment<'_>],
226 report: &mut ValidationReport,
227 _ctx: &ValidationRuleContext<'_>,
228 ) {
229 for e in crate::envelope::validate_envelope_lenient(segments).errors {
234 report_error(report, e);
235 }
236 }
237
238 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
239 Some(Box::new(EnvelopeValidator))
240 }
241}
242
243pub struct CharsetValidator {
281 charset: Option<crate::Charset>,
283}
284
285impl CharsetValidator {
286 #[must_use]
291 pub fn from_envelope() -> Self {
292 Self { charset: None }
293 }
294
295 #[must_use]
300 pub fn with_charset(charset: crate::Charset) -> Self {
301 Self {
302 charset: Some(charset),
303 }
304 }
305
306 fn resolve(&self, segments: &[Segment<'_>]) -> Option<crate::Charset> {
308 if self.charset.is_some() {
309 return self.charset;
310 }
311 let identifier = segments
312 .iter()
313 .find(|s| s.tag == "UNB")
314 .and_then(|unb| unb.component_str(0, 0))?;
315 crate::Charset::from_syntax_identifier(identifier).ok()
319 }
320}
321
322impl Validator for CharsetValidator {
323 fn validate_batch(
324 &self,
325 segments: &[Segment<'_>],
326 report: &mut ValidationReport,
327 _context: &ValidationRuleContext<'_>,
328 ) {
329 let Some(charset) = self.resolve(segments) else {
330 return;
331 };
332 if charset == crate::Charset::UnoY {
333 return; }
335 for segment in segments {
336 for (element_index, element) in segment.elements.iter().enumerate() {
337 for components in element.repetitions() {
338 for (component_index, (value, span)) in components.iter().enumerate() {
339 let Some((offset, character)) = charset.first_violation(value) else {
340 continue;
341 };
342 let mut issue = ValidationIssue::new(
343 ValidationSeverity::Error,
344 format!(
345 "character {character:?} is not in the {charset} character \
346 repertoire declared by UNB S001",
347 ),
348 )
349 .with_error_code(
350 EdifactError::CharacterNotInRepertoire {
351 charset: charset.syntax_identifier(),
352 character,
353 offset,
354 }
355 .stable_code(),
356 )
357 .with_segment(segment.tag)
358 .with_span(*span)
359 .with_suggestion(
360 "Transliterate the value, or declare a wider repertoire in UNB S001 \
361 (UNOC for Latin-1, UNOY for UTF-8)",
362 );
363 if let Ok(index) = u8::try_from(element_index) {
364 issue = issue.with_element_index(index);
365 }
366 if let Ok(index) = u8::try_from(component_index) {
367 issue = issue.with_component_index(index);
368 }
369 report.add_error(issue);
370 }
371 }
372 }
373 }
374 }
375
376 fn fork(&self) -> Option<Box<dyn Validator + Send + Sync>> {
377 Some(Box::new(Self {
378 charset: self.charset,
379 }))
380 }
381}
382
383fn issue_from_error(err: EdifactError) -> ValidationIssue {
384 let code = err.stable_code();
385 let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
386 let default_hint = err.recovery_hint();
387
388 match err {
389 EdifactError::InvalidSegmentForMessage { tag, span, .. } => {
390 issue = issue.with_segment(tag).with_span(span);
391 }
392 EdifactError::InvalidElementCount { tag, span, .. } => {
393 issue = issue.with_segment(tag).with_span(span);
394 }
395 EdifactError::InvalidComponentCount {
396 tag,
397 element_index,
398 span,
399 ..
400 } => {
401 issue = issue
402 .with_segment(tag)
403 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
404 .with_span(span);
405 }
406 EdifactError::InvalidCodeValue {
407 tag,
408 element_index,
409 span,
410 suggestion,
411 ..
412 } => {
413 issue = issue
414 .with_segment(tag)
415 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
416 .with_span(span);
417 if let Some(s) = suggestion {
418 issue = issue.with_suggestion(s);
419 }
420 }
421 EdifactError::MissingSegment { tag, .. } => {
422 issue = issue.with_segment(tag);
423 }
424 EdifactError::QualifierMismatch { tag, span, .. } => {
425 issue = issue
426 .with_segment(tag)
427 .with_element_index(0)
428 .with_span(span);
429 }
430 EdifactError::ConditionalRequirementNotMet {
431 tag,
432 element_index,
433 span,
434 ..
435 } => {
436 issue = issue
437 .with_segment(tag)
438 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
439 .with_span(span);
440 }
441 EdifactError::DuplicateReference { tag, span, .. } => {
442 issue = issue.with_segment(tag).with_span(span);
443 }
444 EdifactError::MissingRequiredElement { tag, element_index } => {
445 issue = issue.with_segment(tag);
446 if let Ok(idx) = u8::try_from(element_index) {
447 issue = issue.with_element_index(idx);
448 }
449 }
450 EdifactError::MissingRequiredComponent {
451 tag,
452 element_index,
453 component_index,
454 } => {
455 issue = issue.with_segment(tag);
456 if let Ok(ei) = u8::try_from(element_index) {
457 issue = issue.with_element_index(ei);
458 }
459 if let Ok(ci) = u8::try_from(component_index) {
460 issue = issue.with_component_index(ci);
461 }
462 }
463 EdifactError::InvalidReleaseSequence { offset }
466 | EdifactError::InvalidDelimiter { offset, .. }
467 | EdifactError::InvalidText { offset }
468 | EdifactError::UnexpectedEof { offset }
469 | EdifactError::UnexpectedDataToken { offset }
470 | EdifactError::SegmentTooLong { offset, .. } => {
471 issue = issue.with_span(Span::new(offset, offset));
472 }
473 _ => {}
474 }
475
476 if issue.suggestion.is_none() {
477 if let Some(hint) = default_hint {
478 issue = issue.with_suggestion(hint);
479 }
480 }
481
482 issue
483}
484
485fn severity_for(err: &EdifactError) -> ValidationSeverity {
486 match err {
487 EdifactError::InvalidCodeValue { .. } => ValidationSeverity::Warning,
495 _ => ValidationSeverity::Error,
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use crate::model::Element;
503
504 fn demo_orders_profile_pack() -> ProfileRulePack {
505 ProfileRulePack::new("ORDERS-DEMO")
506 .for_message_type("ORDERS")
507 .with_stateless_rule_fn(|segments, issues| {
508 issues.extend((|| -> Option<ValidationIssue> {
509 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
510 let document_code = bgm.get_element(0)?.get_component(0)?;
511 (document_code == "220").then(|| {
512 ValidationIssue::new(
513 ValidationSeverity::Error,
514 "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
515 )
516 .with_rule_id("DEMO-P001")
517 .with_segment("BGM")
518 .with_element_index(0)
519 .with_suggestion("Use a different BGM document code in this demo pack")
520 })
521 })());
522 })
523 .with_stateless_rule_fn(|segments, issues| {
524 issues.extend((|| -> Option<ValidationIssue> {
525 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
526 let reference = bgm.get_element(1)?.get_component(0)?;
527 (reference == "PO123").then(|| {
528 ValidationIssue::new(
529 ValidationSeverity::Warning,
530 "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
531 )
532 .with_rule_id("DEMO-P002")
533 .with_segment("BGM")
534 .with_element_index(1)
535 .with_suggestion("Use a non-reserved reference in this demo pack")
536 })
537 })());
538 })
539 }
540
541 struct RejectBgm;
542
543 struct WarnBgm;
544
545 impl Validator for RejectBgm {
546 fn validate_batch(
547 &self,
548 segments: &[Segment<'_>],
549 report: &mut ValidationReport,
550 _context: &ValidationRuleContext<'_>,
551 ) {
552 validate_each(segments, report, |segment| {
553 if segment.tag == "BGM" {
554 return Err(EdifactError::InvalidSegmentForMessage {
555 tag: "BGM".to_owned(),
556 message_type: "TEST".to_owned(),
557 span: segment.tag_span,
558 });
559 }
560 Ok(())
561 });
562 }
563 }
564
565 impl Validator for WarnBgm {
566 fn validate_batch(
567 &self,
568 segments: &[Segment<'_>],
569 report: &mut ValidationReport,
570 _context: &ValidationRuleContext<'_>,
571 ) {
572 validate_each(segments, report, |segment| {
573 if segment.tag == "BGM" {
574 return Err(EdifactError::InvalidCodeValue {
575 tag: "BGM".to_owned(),
576 element_index: 0,
577 value: "XXX".to_owned(),
578 code_list: "1001".to_owned(),
579 span: segment.span,
580 suggestion: None,
581 });
582 }
583 Ok(())
584 });
585 }
586 }
587
588 fn test_segment(tag: &'static str) -> Segment<'static> {
589 Segment {
590 tag,
591 span: crate::Span::new(0, 0),
592 tag_span: crate::Span::new(0, 0),
593 elements: vec![Element::of(&["x"])],
594 }
595 }
596
597 #[test]
598 fn lenient_collects_issues() {
599 let segments = vec![test_segment("UNH"), test_segment("BGM")];
600 let mut report = ValidationReport::default();
601 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
602 assert!(report.has_errors());
603 assert_eq!(report.errors().len(), 1);
604 }
605
606 #[test]
607 fn strict_fails_on_errors() {
608 let segments = vec![test_segment("BGM")];
609 let mut report = ValidationReport::default();
610 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
611 assert!(report.has_errors());
612 assert_eq!(report.errors().len(), 1);
613 }
614
615 #[test]
616 fn context_builder_respects_layer_toggles() {
617 let segments = vec![test_segment("BGM")];
618 let ctx = ValidationContext::builder()
619 .structure(false)
620 .with_validator(ValidationLayer::Structure, RejectBgm)
621 .with_validator(ValidationLayer::CodeList, WarnBgm)
622 .build();
623
624 let report = ctx.validate_lenient(&segments);
625 assert!(!report.has_errors());
626 assert_eq!(report.warnings().len(), 1);
627 }
628
629 #[test]
630 fn context_strict_fails_when_structure_enabled() {
631 let segments = vec![test_segment("BGM")];
632 let ctx = ValidationContext::builder()
633 .with_message_type("ORDERS")
634 .with_validator(ValidationLayer::Structure, RejectBgm)
635 .build();
636
637 assert_eq!(ctx.message_type(), Some("ORDERS"));
638 let result = ctx.validate_strict(&segments);
639 assert!(result.is_err());
640 assert!(result.unwrap_err().has_errors());
641 }
642
643 #[test]
644 fn report_error_applies_default_recovery_hint() {
645 let mut report = ValidationReport::default();
646 report_error(
647 &mut report,
648 EdifactError::InvalidReleaseSequence { offset: 9 },
649 );
650
651 let issue = report
652 .errors()
653 .first()
654 .expect("expected one issue in the report");
655 let hint = issue
656 .suggestion
657 .as_deref()
658 .expect("expected default hint to be set");
659 assert!(hint.contains("Release character"));
660 assert_eq!(issue.error_code(), Some("E019"));
661 }
662
663 #[test]
664 fn missing_required_component_maps_metadata_to_issue() {
665 let mut report = ValidationReport::default();
666 report_error(
667 &mut report,
668 EdifactError::MissingRequiredComponent {
669 tag: "BGM".to_owned(),
670 element_index: 2,
671 component_index: 1,
672 },
673 );
674
675 let issue = report.errors().first().expect("expected one issue");
676 assert_eq!(issue.error_code(), Some("E021"));
677 assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
678 assert_eq!(issue.element_index, Some(2));
679 assert_eq!(issue.component_index, Some(1));
680 }
681
682 #[test]
683 fn profile_pack_lenient_collects_profile_rule_issues() {
684 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
685 let segments = crate::from_bytes(input)
686 .collect::<Result<Vec<_>, _>>()
687 .expect("expected parse success");
688
689 let ctx = ValidationContext::builder()
690 .with_profile_pack(demo_orders_profile_pack())
691 .build();
692
693 let report = ctx.validate_lenient(&segments);
694 assert!(report.has_errors());
695 assert!(
696 report
697 .errors()
698 .iter()
699 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
700 );
701 assert!(
702 report
703 .warnings()
704 .iter()
705 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
706 );
707 }
708
709 #[test]
710 fn profile_pack_strict_fails_when_profile_errors_exist() {
711 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
712 let segments = crate::from_bytes(input)
713 .collect::<Result<Vec<_>, _>>()
714 .expect("expected parse success");
715
716 let ctx = ValidationContext::builder()
717 .with_profile_pack(demo_orders_profile_pack())
718 .build();
719 let result = ctx.validate_strict(&segments);
720 assert!(result.is_err());
721 assert!(result.unwrap_err().has_errors());
722 }
723
724 fn two_dtm_errors_rule() -> ProfileRulePack {
728 ProfileRulePack::new("TEST-BAIL")
729 .with_stateless_rule_fn(|segments, issues| {
730 for seg in segments.iter().filter(|s| s.tag == "DTM") {
732 issues.push(
733 ValidationIssue::new(
734 ValidationSeverity::Error,
735 format!("DTM error at offset {}", seg.span.start),
736 )
737 .with_rule_id("BAIL-R1")
738 .with_segment("DTM"),
739 );
740 }
741 })
742 .with_stateless_rule_fn(|segments, issues| {
743 for seg in segments.iter().filter(|s| s.tag == "BGM") {
745 issues.push(
746 ValidationIssue::new(ValidationSeverity::Error, "BGM error")
747 .with_rule_id("BAIL-R2")
748 .with_segment(seg.tag),
749 );
750 }
751 })
752 }
753
754 #[test]
755 fn bail_on_first_error_fires_at_rule_invocation_granularity() {
756 let input =
759 b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
760 let segments = crate::from_bytes(input)
761 .collect::<Result<Vec<_>, _>>()
762 .expect("parse failed");
763
764 let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
765 let ctx = ValidationContext::builder()
766 .with_profile_pack(pack_with_bail)
767 .build();
768 let report = ctx.validate_lenient(&segments);
769
770 assert_eq!(
773 report
774 .errors()
775 .iter()
776 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
777 .count(),
778 2,
779 "both DTM errors from Rule A should be present"
780 );
781 assert_eq!(
783 report
784 .errors()
785 .iter()
786 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
787 .count(),
788 0,
789 "Rule B should have been skipped by bail"
790 );
791 }
792
793 #[test]
794 fn bail_disabled_runs_all_rules() {
795 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
796 let segments = crate::from_bytes(input)
797 .collect::<Result<Vec<_>, _>>()
798 .expect("parse failed");
799
800 let pack_no_bail = two_dtm_errors_rule(); let ctx = ValidationContext::builder()
802 .with_profile_pack(pack_no_bail)
803 .build();
804 let report = ctx.validate_lenient(&segments);
805
806 assert_eq!(
808 report
809 .errors()
810 .iter()
811 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
812 .count(),
813 1
814 );
815 assert_eq!(
816 report
817 .errors()
818 .iter()
819 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
820 .count(),
821 1
822 );
823 }
824
825 #[test]
828 fn message_ref_is_visible_inside_rule_closure() {
829 let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
830 let segments = crate::from_bytes(input)
831 .collect::<Result<Vec<_>, _>>()
832 .expect("parse failed");
833
834 let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
835 if let Some(mref) = ctx.message_ref {
836 issues.push(
837 ValidationIssue::new(
838 ValidationSeverity::Info,
839 format!("validating message {mref}"),
840 )
841 .with_rule_id("CTX-REF"),
842 );
843 }
844 });
845
846 let ctx = ValidationContext::builder()
847 .with_profile_pack(pack)
848 .with_message_ref("MSG001")
849 .build();
850
851 let report = ctx.validate_lenient(&segments);
852 let info = report
853 .infos()
854 .iter()
855 .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
856 .expect("expected info issue from CTX-REF rule");
857 assert!(info.message.contains("MSG001"));
858 assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
860 }
861}