1pub mod context;
11pub mod pack;
12
13pub use context::{ValidationContext, ValidationContextBuilder};
14pub use pack::{ProfileRule, ProfileRulePack};
15
16use crate::{EdifactError, Segment, 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
243fn issue_from_error(err: EdifactError) -> ValidationIssue {
244 let code = err.stable_code();
245 let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
246 let default_hint = err.recovery_hint();
247
248 match err {
249 EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
250 issue = issue.with_segment(tag).with_offset(offset);
251 }
252 EdifactError::InvalidElementCount { tag, offset, .. } => {
253 issue = issue.with_segment(tag).with_offset(offset);
254 }
255 EdifactError::InvalidComponentCount {
256 tag,
257 element_index,
258 offset,
259 ..
260 } => {
261 issue = issue
262 .with_segment(tag)
263 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
264 .with_offset(offset);
265 }
266 EdifactError::InvalidCodeValue {
267 tag,
268 element_index,
269 offset,
270 suggestion,
271 ..
272 } => {
273 issue = issue
274 .with_segment(tag)
275 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
276 .with_offset(offset);
277 if let Some(s) = suggestion {
278 issue = issue.with_suggestion(s);
279 }
280 }
281 EdifactError::MissingSegment { tag, .. } => {
282 issue = issue.with_segment(tag);
283 }
284 EdifactError::QualifierMismatch { tag, offset, .. } => {
285 issue = issue
286 .with_segment(tag)
287 .with_element_index(0)
288 .with_offset(offset);
289 }
290 EdifactError::ConditionalRequirementNotMet {
291 tag,
292 element_index,
293 offset,
294 ..
295 } => {
296 issue = issue
297 .with_segment(tag)
298 .with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
299 .with_offset(offset);
300 }
301 EdifactError::MissingRequiredElement { tag, element_index } => {
302 issue = issue.with_segment(tag);
303 if let Ok(idx) = u8::try_from(element_index) {
304 issue = issue.with_element_index(idx);
305 }
306 }
307 EdifactError::MissingRequiredComponent {
308 tag,
309 element_index,
310 component_index,
311 } => {
312 issue = issue.with_segment(tag);
313 if let Ok(ei) = u8::try_from(element_index) {
314 issue = issue.with_element_index(ei);
315 }
316 if let Ok(ci) = u8::try_from(component_index) {
317 issue = issue.with_component_index(ci);
318 }
319 }
320 EdifactError::InvalidReleaseSequence { offset }
321 | EdifactError::InvalidDelimiter { offset, .. }
322 | EdifactError::InvalidText { offset }
323 | EdifactError::UnexpectedEof { offset }
324 | EdifactError::UnexpectedDataToken { offset } => {
325 issue = issue.with_offset(offset);
326 }
327 _ => {}
328 }
329
330 if issue.suggestion.is_none() {
331 if let Some(hint) = default_hint {
332 issue = issue.with_suggestion(hint);
333 }
334 }
335
336 issue
337}
338
339fn severity_for(err: &EdifactError) -> ValidationSeverity {
340 match err {
341 EdifactError::InvalidCodeValue { .. } => ValidationSeverity::Warning,
349 _ => ValidationSeverity::Error,
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::model::Element;
357
358 fn demo_orders_profile_pack() -> ProfileRulePack {
359 ProfileRulePack::new("ORDERS-DEMO")
360 .for_message_type("ORDERS")
361 .with_stateless_rule_fn(|segments, issues| {
362 issues.extend((|| -> Option<ValidationIssue> {
363 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
364 let document_code = bgm.get_element(0)?.get_component(0)?;
365 (document_code == "220").then(|| {
366 ValidationIssue::new(
367 ValidationSeverity::Error,
368 "profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
369 )
370 .with_rule_id("DEMO-P001")
371 .with_segment("BGM")
372 .with_element_index(0)
373 .with_suggestion("Use a different BGM document code in this demo pack")
374 })
375 })());
376 })
377 .with_stateless_rule_fn(|segments, issues| {
378 issues.extend((|| -> Option<ValidationIssue> {
379 let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
380 let reference = bgm.get_element(1)?.get_component(0)?;
381 (reference == "PO123").then(|| {
382 ValidationIssue::new(
383 ValidationSeverity::Warning,
384 "profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
385 )
386 .with_rule_id("DEMO-P002")
387 .with_segment("BGM")
388 .with_element_index(1)
389 .with_suggestion("Use a non-reserved reference in this demo pack")
390 })
391 })());
392 })
393 }
394
395 struct RejectBgm;
396
397 struct WarnBgm;
398
399 impl Validator for RejectBgm {
400 fn validate_batch(
401 &self,
402 segments: &[Segment<'_>],
403 report: &mut ValidationReport,
404 _context: &ValidationRuleContext<'_>,
405 ) {
406 validate_each(segments, report, |segment| {
407 if segment.tag == "BGM" {
408 return Err(EdifactError::InvalidSegmentForMessage {
409 tag: "BGM".to_owned(),
410 message_type: "TEST".to_owned(),
411 offset: segment.tag_span.start,
412 });
413 }
414 Ok(())
415 });
416 }
417 }
418
419 impl Validator for WarnBgm {
420 fn validate_batch(
421 &self,
422 segments: &[Segment<'_>],
423 report: &mut ValidationReport,
424 _context: &ValidationRuleContext<'_>,
425 ) {
426 validate_each(segments, report, |segment| {
427 if segment.tag == "BGM" {
428 return Err(EdifactError::InvalidCodeValue {
429 tag: "BGM".to_owned(),
430 element_index: 0,
431 value: "XXX".to_owned(),
432 code_list: "1001".to_owned(),
433 offset: segment.span.start,
434 suggestion: None,
435 });
436 }
437 Ok(())
438 });
439 }
440 }
441
442 fn test_segment(tag: &'static str) -> Segment<'static> {
443 Segment {
444 tag,
445 span: crate::Span::new(0, 0),
446 tag_span: crate::Span::new(0, 0),
447 elements: vec![Element::of(&["x"])],
448 }
449 }
450
451 #[test]
452 fn lenient_collects_issues() {
453 let segments = vec![test_segment("UNH"), test_segment("BGM")];
454 let mut report = ValidationReport::default();
455 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
456 assert!(report.has_errors());
457 assert_eq!(report.errors().len(), 1);
458 }
459
460 #[test]
461 fn strict_fails_on_errors() {
462 let segments = vec![test_segment("BGM")];
463 let mut report = ValidationReport::default();
464 RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
465 assert!(report.has_errors());
466 assert_eq!(report.errors().len(), 1);
467 }
468
469 #[test]
470 fn context_builder_respects_layer_toggles() {
471 let segments = vec![test_segment("BGM")];
472 let ctx = ValidationContext::builder()
473 .structure(false)
474 .with_validator(ValidationLayer::Structure, RejectBgm)
475 .with_validator(ValidationLayer::CodeList, WarnBgm)
476 .build();
477
478 let report = ctx.validate_lenient(&segments);
479 assert!(!report.has_errors());
480 assert_eq!(report.warnings().len(), 1);
481 }
482
483 #[test]
484 fn context_strict_fails_when_structure_enabled() {
485 let segments = vec![test_segment("BGM")];
486 let ctx = ValidationContext::builder()
487 .with_message_type("ORDERS")
488 .with_validator(ValidationLayer::Structure, RejectBgm)
489 .build();
490
491 assert_eq!(ctx.message_type(), Some("ORDERS"));
492 let result = ctx.validate_strict(&segments);
493 assert!(result.is_err());
494 assert!(result.unwrap_err().has_errors());
495 }
496
497 #[test]
498 fn report_error_applies_default_recovery_hint() {
499 let mut report = ValidationReport::default();
500 report_error(
501 &mut report,
502 EdifactError::InvalidReleaseSequence { offset: 9 },
503 );
504
505 let issue = report
506 .errors()
507 .first()
508 .expect("expected one issue in the report");
509 let hint = issue
510 .suggestion
511 .as_deref()
512 .expect("expected default hint to be set");
513 assert!(hint.contains("Release character"));
514 assert_eq!(issue.error_code, Some("E019"));
515 }
516
517 #[test]
518 fn missing_required_component_maps_metadata_to_issue() {
519 let mut report = ValidationReport::default();
520 report_error(
521 &mut report,
522 EdifactError::MissingRequiredComponent {
523 tag: "BGM".to_owned(),
524 element_index: 2,
525 component_index: 1,
526 },
527 );
528
529 let issue = report.errors().first().expect("expected one issue");
530 assert_eq!(issue.error_code, Some("E021"));
531 assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
532 assert_eq!(issue.element_index, Some(2));
533 assert_eq!(issue.component_index, Some(1));
534 }
535
536 #[test]
537 fn profile_pack_lenient_collects_profile_rule_issues() {
538 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
539 let segments = crate::from_bytes(input)
540 .collect::<Result<Vec<_>, _>>()
541 .expect("expected parse success");
542
543 let ctx = ValidationContext::builder()
544 .with_profile_pack(demo_orders_profile_pack())
545 .build();
546
547 let report = ctx.validate_lenient(&segments);
548 assert!(report.has_errors());
549 assert!(
550 report
551 .errors()
552 .iter()
553 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
554 );
555 assert!(
556 report
557 .warnings()
558 .iter()
559 .any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
560 );
561 }
562
563 #[test]
564 fn profile_pack_strict_fails_when_profile_errors_exist() {
565 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
566 let segments = crate::from_bytes(input)
567 .collect::<Result<Vec<_>, _>>()
568 .expect("expected parse success");
569
570 let ctx = ValidationContext::builder()
571 .with_profile_pack(demo_orders_profile_pack())
572 .build();
573 let result = ctx.validate_strict(&segments);
574 assert!(result.is_err());
575 assert!(result.unwrap_err().has_errors());
576 }
577
578 fn two_dtm_errors_rule() -> ProfileRulePack {
582 ProfileRulePack::new("TEST-BAIL")
583 .with_stateless_rule_fn(|segments, issues| {
584 for seg in segments.iter().filter(|s| s.tag == "DTM") {
586 issues.push(
587 ValidationIssue::new(
588 ValidationSeverity::Error,
589 format!("DTM error at offset {}", seg.span.start),
590 )
591 .with_rule_id("BAIL-R1")
592 .with_segment("DTM"),
593 );
594 }
595 })
596 .with_stateless_rule_fn(|segments, issues| {
597 for seg in segments.iter().filter(|s| s.tag == "BGM") {
599 issues.push(
600 ValidationIssue::new(ValidationSeverity::Error, "BGM error")
601 .with_rule_id("BAIL-R2")
602 .with_segment(seg.tag),
603 );
604 }
605 })
606 }
607
608 #[test]
609 fn bail_on_first_error_fires_at_rule_invocation_granularity() {
610 let input =
613 b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
614 let segments = crate::from_bytes(input)
615 .collect::<Result<Vec<_>, _>>()
616 .expect("parse failed");
617
618 let pack_with_bail = two_dtm_errors_rule().with_bail_on_first_error(true);
619 let ctx = ValidationContext::builder()
620 .with_profile_pack(pack_with_bail)
621 .build();
622 let report = ctx.validate_lenient(&segments);
623
624 assert_eq!(
627 report
628 .errors()
629 .iter()
630 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
631 .count(),
632 2,
633 "both DTM errors from Rule A should be present"
634 );
635 assert_eq!(
637 report
638 .errors()
639 .iter()
640 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
641 .count(),
642 0,
643 "Rule B should have been skipped by bail"
644 );
645 }
646
647 #[test]
648 fn bail_disabled_runs_all_rules() {
649 let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
650 let segments = crate::from_bytes(input)
651 .collect::<Result<Vec<_>, _>>()
652 .expect("parse failed");
653
654 let pack_no_bail = two_dtm_errors_rule(); let ctx = ValidationContext::builder()
656 .with_profile_pack(pack_no_bail)
657 .build();
658 let report = ctx.validate_lenient(&segments);
659
660 assert_eq!(
662 report
663 .errors()
664 .iter()
665 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
666 .count(),
667 1
668 );
669 assert_eq!(
670 report
671 .errors()
672 .iter()
673 .filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
674 .count(),
675 1
676 );
677 }
678
679 #[test]
682 fn message_ref_is_visible_inside_rule_closure() {
683 let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
684 let segments = crate::from_bytes(input)
685 .collect::<Result<Vec<_>, _>>()
686 .expect("parse failed");
687
688 let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
689 if let Some(mref) = ctx.message_ref {
690 issues.push(
691 ValidationIssue::new(
692 ValidationSeverity::Info,
693 format!("validating message {mref}"),
694 )
695 .with_rule_id("CTX-REF"),
696 );
697 }
698 });
699
700 let ctx = ValidationContext::builder()
701 .with_profile_pack(pack)
702 .with_message_ref("MSG001")
703 .build();
704
705 let report = ctx.validate_lenient(&segments);
706 let info = report
707 .infos()
708 .iter()
709 .find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
710 .expect("expected info issue from CTX-REF rule");
711 assert!(info.message.contains("MSG001"));
712 assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
714 }
715}