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