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