use edifact_rs::{
ProfileRulePack, ValidationContext, ValidationIssue, ValidationSeverity,
group::{GroupDef, group_owned_segments_indexed, group_segments_indexed},
};
static SCHEMA: &[GroupDef] = &[
GroupDef {
name: "SG1",
trigger: "RFF",
children: &[],
},
GroupDef {
name: "SG5",
trigger: "LOC",
children: &[GroupDef {
name: "SG6",
trigger: "QTY",
children: &[],
}],
},
];
fn owned_segs(input: &[u8]) -> Vec<edifact_rs::OwnedSegment> {
edifact_rs::from_reader(std::io::Cursor::new(input))
.collect::<Result<_, _>>()
.expect("fixture should parse")
}
fn parse_segs(input: &str) -> Vec<edifact_rs::Segment<'static>> {
let s: &'static str = Box::leak(input.to_owned().into_boxed_str());
edifact_rs::from_bytes(s.as_bytes())
.collect::<Result<_, _>>()
.expect("fixture should parse")
}
#[test]
fn group_rule_fires_when_required_segment_absent_from_scoped_group() {
let input = "UNH+1+MSCONS:D:04B:UN'RFF+Z13:REF1'DTM+137:20230101:102'LOC+172+LOC1'UNT+5+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(
report.has_errors(),
"expected error: DTM missing from SG5 — got: {report}"
);
let issue = report.errors().first().expect("at least one error");
assert_eq!(issue.segment_group.as_deref(), Some("SG5"));
assert_eq!(issue.rule_id.as_deref(), Some("SG5-DTM-M"));
}
#[test]
fn group_rule_does_not_fire_when_segment_present_in_scoped_group() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+LOC1'DTM+137:20230101:102'UNT+3+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(
report.is_valid(),
"expected no errors (DTM is present in SG5): {report}"
);
}
#[test]
fn forbid_segment_in_group_fires_when_segment_present() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+LOC1'UNS+D'UNT+3+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").forbid_segment_in_group("SG5", "UNS", "SG5-UNS-F");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(report.has_errors(), "expected error: UNS in SG5 — {report}");
let issue = report.errors().first().unwrap();
assert_eq!(issue.segment_group.as_deref(), Some("SG5"));
}
#[test]
fn forbid_segment_in_group_does_not_fire_when_segment_absent() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+LOC1'DTM+137:20230101:102'UNT+3+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").forbid_segment_in_group("SG5", "UNS", "SG5-UNS-F");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
assert!(ctx.validate_lenient_grouped(&tree, &segs).is_valid());
}
#[test]
fn group_rules_for_different_groups_do_not_cross_contaminate() {
let input =
"UNH+1+MSCONS:D:04B:UN'RFF+Z13:R1'DTM+137:20230101:102'LOC+172+L1'QTY+220:100'UNT+5+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST")
.require_segment_in_group("SG1", "DTM", "SG1-DTM-M")
.require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert_eq!(report.errors().len(), 1, "expected 1 error; got {report}");
assert_eq!(
report.errors()[0].rule_id.as_deref(),
Some("SG5-DTM-M"),
"the error must be for SG5, not SG1"
);
}
#[test]
fn group_rule_issues_are_auto_stamped_with_group_name() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+L1'UNT+2+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").require_segment_in_group("SG5", "QTY", "SG5-QTY-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(report.has_errors());
for issue in report.errors() {
assert_eq!(
issue.segment_group.as_deref(),
Some("SG5"),
"issue must be auto-stamped with SG5"
);
}
}
#[test]
fn validate_lenient_grouped_owned_works_with_owned_segments() {
let input = b"UNH+1+MSCONS:D:04B:UN'LOC+172+L1'DTM+137:20230101:102'UNT+3+1'";
let owned = owned_segs(input);
let tree = group_owned_segments_indexed(&owned, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped_owned(&tree, &owned);
assert!(
report.is_valid(),
"DTM present in SG5 — no errors expected: {report}"
);
}
#[test]
fn group_rule_fires_per_occurrence_when_group_repeats() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+L1'DTM+137:20230101:102'LOC+172+L2'UNT+4+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert_eq!(
report.errors().len(),
1,
"exactly one SG5 missing DTM: {report}"
);
}
#[test]
fn custom_scoped_group_rule_fn_fires_and_sets_segment_group() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+L1'QTY+220:0'UNT+3+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").with_scoped_group_rule_fn(
"SG6",
"SG6-QTY-NONZERO",
|_group, group_segs, _ctx, issues| {
for s in group_segs.iter().filter(|s| s.tag == "QTY") {
let qty_val = s
.get_element(0)
.and_then(|e| e.get_component(1))
.unwrap_or("0");
if qty_val == "0" {
issues.push(
ValidationIssue::new(
ValidationSeverity::Warning,
"QTY value is zero in SG6",
)
.with_segment("QTY")
.with_rule_id("SG6-QTY-NONZERO"),
);
}
}
},
);
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(!report.warnings().is_empty(), "expected zero-qty warning");
assert_eq!(
report.warnings()[0].segment_group.as_deref(),
Some("SG6"),
"warning must be auto-stamped with SG6"
);
}
#[test]
fn group_rules_respect_message_type_scoping() {
let input = "UNH+1+INVOIC:D:96A:UN'LOC+172+L1'UNT+2+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("ORDERS-ONLY")
.for_message_type("ORDERS")
.require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(
report.is_valid(),
"INVOIC message: ORDERS-scoped group rules must not fire: {report}"
);
}
#[test]
fn flat_and_group_validation_both_run_in_grouped_mode() {
let input = "UNH+1+MSCONS:D:04B:UN'LOC+172+L1'UNT+2+1'";
let segs = parse_segs(input);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST")
.require_segment("BGM", "BGM-M")
.require_segment_in_group("SG5", "DTM", "SG5-DTM-M");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
let rule_ids: Vec<Option<&str>> = report
.errors()
.iter()
.map(|i| i.rule_id.as_deref())
.collect();
assert!(
rule_ids.contains(&Some("BGM-M")),
"flat rule BGM-M must fire: {report}"
);
assert!(
rule_ids.contains(&Some("SG5-DTM-M")),
"group rule SG5-DTM-M must fire: {report}"
);
}
#[test]
fn forbid_segment_segment_occurrence_is_relative_not_absolute() {
let segs = parse_segs("UNH+1+ORDERS:D:96A:UN'QTY+21:10'QTY+21:20'QTY+21:30'UNT+4+1'");
let pack = ProfileRulePack::new("TEST").forbid_segment("QTY", "TEST-FORBID-QTY");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient(&segs);
let mut occurrences: Vec<u16> = report
.errors()
.iter()
.filter_map(|i| i.segment_occurrence)
.collect();
occurrences.sort_unstable();
assert_eq!(
occurrences,
vec![0, 1, 2],
"segment_occurrence must be 0-based relative to matching segments, not absolute positions"
);
}
#[test]
fn forbid_segment_in_group_occurrence_is_relative_not_absolute() {
let segs = parse_segs("UNH+1+MSCONS:D:04B:UN'LOC+172+L1'QTY+21:10'QTY+21:20'UNT+4+1'");
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST").forbid_segment_in_group("SG5", "QTY", "TEST-SG5-QTY");
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
let mut occurrences: Vec<u16> = report
.errors()
.iter()
.filter_map(|i| i.segment_occurrence)
.collect();
occurrences.sort_unstable();
assert_eq!(
occurrences,
vec![0, 1],
"segment_occurrence in group must count only matching segments, not absolute group slice position"
);
}
#[test]
fn bail_on_first_error_does_not_skip_sibling_groups_due_to_earlier_flat_errors() {
let segs = parse_segs(
"UNH+1+MSCONS:D:04B:UN'\
LOC+172+L1'LOC+172+L2'UNT+3+1'",
);
let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
let pack = ProfileRulePack::new("TEST")
.require_segment("BGM", "BGM-M")
.require_segment_in_group("SG5", "DTM", "SG5-DTM-M")
.with_bail_on_first_error(true);
let ctx = ValidationContext::builder().with_profile_pack(pack).build();
let report = ctx.validate_lenient_grouped(&tree, &segs);
assert!(report.has_errors(), "expected errors in report: {report}");
let rule_ids: Vec<&str> = report
.errors()
.iter()
.filter_map(|i| i.rule_id.as_deref())
.collect();
assert!(
rule_ids.contains(&"BGM-M"),
"flat error BGM-M must be present: {report}"
);
assert!(
rule_ids.contains(&"SG5-DTM-M"),
"group error SG5-DTM-M must not be skipped by pre-existing flat errors: {report}"
);
}