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