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