edifact-rs 0.13.0

Zero-copy EDIFACT parser, writer, serde traits, and extensible validation support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Integration tests for group-scoped validation (F-011 / F-029).
//!
//! These tests verify that [`ProfileRulePack`] group rules:
//! - Only fire for the group they are scoped to.
//! - Do **not** cross-fire when the same segment tag appears in a different group.
//! - Correctly auto-stamp `ValidationIssue::segment_group`.
//! - Work through [`ValidationContext::validate_lenient_grouped`].
//! - Work through [`ValidationContext::validate_lenient_grouped_owned`].

use edifact_rs::{
    ProfileRulePack, ValidationContext, ValidationIssue, ValidationSeverity,
    group::{GroupDef, group_owned_segments_indexed, group_segments_indexed},
};

// ── Schema shared across tests ────────────────────────────────────────────────

/// Minimal MSCONS-like schema:
///
/// ```text
/// ROOT
///   SG1 (trigger: RFF) — reference group
///     (no children)
///   SG5 (trigger: LOC) — location group
///     SG6 (trigger: QTY) — quantity sub-group
/// ```
static SCHEMA: &[GroupDef] = &[
    GroupDef {
        name: "SG1",
        trigger: "RFF",
        children: &[],
    },
    GroupDef {
        name: "SG5",
        trigger: "LOC",
        children: &[GroupDef {
            name: "SG6",
            trigger: "QTY",
            children: &[],
        }],
    },
];

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Parse ASCII EDIFACT snippet into owned segments.
fn owned_segs(input: &[u8]) -> Vec<edifact_rs::OwnedSegment> {
    edifact_rs::from_reader(std::io::Cursor::new(input))
        .collect::<Result<_, _>>()
        .expect("fixture should parse")
}

/// Parse ASCII EDIFACT snippet into borrowed segments (from a String so we own the buffer).
fn parse_segs(input: &str) -> Vec<edifact_rs::Segment<'static>> {
    // Leak the string so we get 'static lifetime — acceptable in tests only.
    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")
}

// ── F-029 Test 1: group rule fires only for the scoped group ──────────────────

/// A rule scoped to "SG5" DOES fire when DTM is absent from SG5, even though it
/// appears in SG1. Cross-group contamination must not suppress the missing-segment
/// error for SG5.
#[test]
fn group_rule_fires_when_required_segment_absent_from_scoped_group() {
    // DTM is in SG1 (after RFF), not in SG5 (LOC).  Rule: DTM must be in SG5.
    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);
    // DTM is present in SG1 but NOT in SG5, so the rule should fire.
    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"));
}

/// A rule scoped to "SG5" must NOT fire when DTM is present in SG5.
#[test]
fn group_rule_does_not_fire_when_segment_present_in_scoped_group() {
    // DTM inside SG5 (after LOC).
    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}"
    );
}

// ── F-029 Test 2: forbid_segment_in_group ─────────────────────────────────────

#[test]
fn forbid_segment_in_group_fires_when_segment_present() {
    // UNS must not appear in SG5.
    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());
}

// ── F-029 Test 3: cross-group non-contamination ───────────────────────────────

/// The same segment tag in two different groups must produce independent issues:
/// a rule on SG5/DTM must not mark the SG1/DTM occurrence and vice-versa.
#[test]
fn group_rules_for_different_groups_do_not_cross_contaminate() {
    // DTM in SG1 (after RFF) but not in SG5 (after LOC), and QTY in SG6 (after QTY trigger).
    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");

    // SG1 rule: require DTM (present → no error)
    // SG5 rule: require DTM (absent in SG5 → error)
    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);
    // Exactly one error (SG5 missing DTM), not two.
    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"
    );
}

// ── F-029 Test 4: segment_group auto-stamped ──────────────────────────────────

#[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());
    // Auto-stamp: segment_group must equal the group definition name.
    for issue in report.errors() {
        assert_eq!(
            issue.segment_group.as_deref(),
            Some("SG5"),
            "issue must be auto-stamped with SG5"
        );
    }
}

// ── F-029 Test 5: validate_lenient_grouped_owned ──────────────────────────────

#[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}"
    );
}

// ── F-029 Test 6: multiple group occurrences (repetition) ────────────────────

/// When SG5 repeats, the rule fires per-occurrence.
#[test]
fn group_rule_fires_per_occurrence_when_group_repeats() {
    // Two SG5 groups: first has DTM, second does not.
    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);
    // One SG5 is missing DTM → exactly one error.
    assert_eq!(
        report.errors().len(),
        1,
        "exactly one SG5 missing DTM: {report}"
    );
}

// ── F-029 Test 7: with_scoped_group_rule_fn custom closure ───────────────────

#[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"
    );
}

// ── F-029 Test 8: message-type scoping works with group rules ─────────────────

#[test]
fn group_rules_respect_message_type_scoping() {
    // Pack scoped to ORDERS; message is INVOIC → no group rules should fire.
    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}"
    );
}

// ── F-029 Test 9: flat + group phases both run ─────────────────────────────────

#[test]
fn flat_and_group_validation_both_run_in_grouped_mode() {
    // Flat rule: BGM must be present (it's absent).
    // Group rule: DTM must be in SG5 (also absent).
    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);
    // Both a flat error and a group error should appear.
    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}"
    );
}

// ── segment_occurrence semantics: occurrence among matching segments, not absolute ─

#[test]
fn forbid_segment_segment_occurrence_is_relative_not_absolute() {
    // Three QTY segments at absolute positions 1, 2, 3 in the message.
    // forbid_segment fires for each; occurrences must be 0, 1, 2 (relative).
    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() {
    // SG5 group (trigger: LOC) with two QTY segments at positions 1 and 2 within the group.
    // LOC is at absolute 0 within the group slice; occurrences for QTY must be 0 and 1.
    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"
    );
}

// ── bail_on_first_error: child traversal must not stop on pre-existing errors ─

#[test]
fn bail_on_first_error_does_not_skip_sibling_groups_due_to_earlier_flat_errors() {
    // Two SG5 groups (LOC+L1 and LOC+L2), each missing DTM.
    // The flat pass also fires an error (BGM missing).
    // With bail_on_first_error the group pass should stop after the FIRST group
    // error it introduces — not skip all group rules because the flat pass
    // already put errors in the report before the group pass started.
    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");

    // A pack with bail_on_first_error that requires DTM in every SG5.
    // Also require BGM (flat) — this fires first and puts an error in the report.
    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);

    // We must have at least the flat BGM error AND at least one group error.
    // (bail_on_first_error stops after the first group error from THIS pass,
    // not because the flat pass already populated errors.)
    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}"
    );
}