Skip to main content

automapper_validation/validator/
tree.rs

1//! Merged AHB + EDIFACT tree for validation.
2//!
3//! [`build_validated_tree`] joins an [`AhbWorkflow`] (what should be there)
4//! with an [`AssembledTree`] (what is there) into a single [`ValidatedTree`]
5//! where each node carries both the AHB rule and the resolved EDIFACT value.
6
7use std::collections::HashMap;
8
9use crate::expr::ConditionExpr;
10
11use super::validate::{AhbFieldRule, AhbWorkflow};
12use mig_assembly::assembler::{
13    AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
14};
15
16/// A single AHB field matched against EDIFACT data.
17#[derive(Debug)]
18pub struct AhbNode<'a> {
19    /// The AHB field rule (segment path, ahb_status, codes, etc.)
20    pub rule: &'a AhbFieldRule,
21    /// The actual value found in the EDIFACT at this position. `None` if absent.
22    pub value: Option<&'a str>,
23    /// The full segment elements for cross-element access. `None` if segment absent.
24    pub segment_elements: Option<&'a [Vec<String>]>,
25}
26
27/// A segment group instance matched against EDIFACT data.
28#[derive(Debug)]
29pub struct AhbGroupNode<'a> {
30    /// Group ID (e.g., "SG4", "SG8").
31    pub group_id: &'a str,
32    /// AHB status of this group.
33    pub ahb_status: Option<&'a str>,
34    /// Fields in this group instance, resolved against EDIFACT segments.
35    pub fields: Vec<AhbNode<'a>>,
36    /// Child group instances.
37    pub children: Vec<AhbGroupNode<'a>>,
38}
39
40/// AHB workflow merged with assembled EDIFACT data.
41#[derive(Debug)]
42pub struct ValidatedTree<'a> {
43    /// The Pruefidentifikator.
44    pub pruefidentifikator: &'a str,
45    /// UB definitions for condition expansion.
46    pub ub_definitions: &'a HashMap<String, ConditionExpr>,
47    /// Root-level fields (outside segment groups).
48    pub root_fields: Vec<AhbNode<'a>>,
49    /// Top-level group instances.
50    pub groups: Vec<AhbGroupNode<'a>>,
51    /// Rules whose `mig_number` did not match any assembled group instance.
52    ///
53    /// These represent entirely-absent group variants (e.g., NAD+MS when only
54    /// NAD+MR is present). `validate_tree` evaluates these using the flat
55    /// `is_field_present`/`is_group_variant_absent` logic to correctly
56    /// distinguish mandatory-but-missing variants from optional-and-absent ones.
57    pub unmatched_rules: Vec<&'a AhbFieldRule>,
58}
59
60/// Build a [`ValidatedTree`] by merging an AHB workflow with an assembled EDIFACT tree.
61///
62/// The join key is `mig_number`: both [`AhbFieldRule::mig_number`] and
63/// [`AssembledSegment::mig_number`] carry the MIG `Number` attribute that
64/// uniquely identifies a segment variant.
65pub fn build_validated_tree<'a>(
66    workflow: &'a AhbWorkflow,
67    tree: &'a AssembledTree,
68) -> ValidatedTree<'a> {
69    // Partition AHB rules into root-level and per-group buckets.
70    let mut root_rules: Vec<&'a AhbFieldRule> = Vec::new();
71    // Map from top-level group prefix (e.g., "SG2", "SG4") to rules.
72    let mut group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
73
74    for rule in &workflow.fields {
75        match extract_top_group(&rule.segment_path) {
76            Some(group_id) => {
77                group_rules
78                    .entry(group_id.to_owned())
79                    .or_default()
80                    .push(rule);
81            }
82            None => {
83                root_rules.push(rule);
84            }
85        }
86    }
87
88    // Resolve root-level fields against root segments.
89    let root_fields = resolve_fields(&root_rules, &tree.segments);
90
91    // Resolve groups recursively (depth 0 = top-level groups).
92    let groups = resolve_groups(&tree.groups, &group_rules, 0);
93
94    // Find rules whose mig_number wasn't matched to any tree node.
95    // Collect all mig_numbers that appear in the assembled tree.
96    let mut matched_mig_numbers: std::collections::HashSet<&str> =
97        std::collections::HashSet::new();
98    collect_matched_mig_numbers_from_groups(&groups, &mut matched_mig_numbers);
99    // Also include root segment mig_numbers.
100    for seg in &tree.segments {
101        if let Some(ref num) = seg.mig_number {
102            matched_mig_numbers.insert(num.as_str());
103        }
104    }
105
106    let unmatched_rules: Vec<&AhbFieldRule> = workflow
107        .fields
108        .iter()
109        .filter(|rule| {
110            rule.mig_number
111                .as_ref()
112                .is_some_and(|num| !matched_mig_numbers.contains(num.as_str()))
113        })
114        .collect();
115
116    ValidatedTree {
117        pruefidentifikator: &workflow.pruefidentifikator,
118        ub_definitions: &workflow.ub_definitions,
119        root_fields,
120        groups,
121        unmatched_rules,
122    }
123}
124
125/// Collect all mig_numbers that nodes in the tree were resolved against.
126///
127/// This walks the assembled tree structure (not the AHB nodes) to find
128/// which mig_numbers are "claimed" by existing group instances.
129fn collect_matched_mig_numbers_from_groups<'a>(
130    groups: &[AhbGroupNode<'a>],
131    out: &mut std::collections::HashSet<&'a str>,
132) {
133    for group in groups {
134        for node in &group.fields {
135            if let Some(ref num) = node.rule.mig_number {
136                if node.value.is_some() || node.segment_elements.is_some() {
137                    // Rule was matched to an actual segment.
138                    out.insert(num.as_str());
139                }
140            }
141            // Also count rules that are in the tree at all (even with None value)
142            // because they were accepted by the variant filter.
143            if let Some(ref num) = node.rule.mig_number {
144                out.insert(num.as_str());
145            }
146        }
147        collect_matched_mig_numbers_from_groups(&group.children, out);
148    }
149}
150
151/// Extract the top-level segment group from a segment path.
152///
153/// `"SG4/DTM/C507/2380"` -> `Some("SG4")`
154/// `"SG4/SG5/LOC/3225"` -> `Some("SG4")`
155/// `"BGM/1004"` -> `None`
156fn extract_top_group(segment_path: &str) -> Option<&str> {
157    let first = segment_path.split('/').next()?;
158    if first.starts_with("SG") {
159        Some(first)
160    } else {
161        None
162    }
163}
164
165/// Extract the child group prefix from a path that already had parent groups stripped.
166///
167/// `"SG5/LOC/3225"` -> `Some("SG5")`
168/// `"DTM/C507/2380"` -> `None`
169fn extract_child_group(stripped_path: &str) -> Option<&str> {
170    let first = stripped_path.split('/').next()?;
171    if first.starts_with("SG") {
172        Some(first)
173    } else {
174        None
175    }
176}
177
178/// Resolve AHB fields against a list of assembled segments.
179///
180/// For each rule, tries to find a matching segment by `mig_number` first,
181/// then falls back to segment tag matching. Rules that don't match any
182/// segment get `value: None` — condition evaluation decides if that's an error.
183fn resolve_fields<'a>(
184    rules: &[&'a AhbFieldRule],
185    segments: &'a [AssembledSegment],
186) -> Vec<AhbNode<'a>> {
187    rules
188        .iter()
189        .map(|rule| {
190            let matched_segment = find_segment(rule, segments);
191            let (value, elements) = match matched_segment {
192                Some(seg) => {
193                    let val = extract_value(seg, rule);
194                    (val, Some(seg.elements.as_slice()))
195                }
196                None => (None, None),
197            };
198            AhbNode {
199                rule,
200                value,
201                segment_elements: elements,
202            }
203        })
204        .collect()
205}
206
207/// Find the assembled segment matching an AHB field rule.
208///
209/// Matching strategy when the rule has a `mig_number`:
210/// 1. **Strict match**: find a segment with the exact `mig_number`.
211/// 2. **Tag fallback**: if no segment has that `mig_number`, fall back to tag
212///    matching — but ONLY against segments that don't have a *different*
213///    `mig_number` assigned. This prevents cross-matching between same-tag
214///    segments with known identities (e.g., DTM+92 rules must not match a
215///    DTM+93 segment that has `mig_number` "00024").
216/// 3. Segments with `mig_number: None` (unattributed) are eligible for the
217///    tag fallback, preserving backward compatibility with greedy assembly.
218fn find_segment<'a>(
219    rule: &AhbFieldRule,
220    segments: &'a [AssembledSegment],
221) -> Option<&'a AssembledSegment> {
222    if let Some(ref mig_num) = rule.mig_number {
223        // 1. Strict mig_number match.
224        if let Some(seg) = segments
225            .iter()
226            .find(|s| s.mig_number.as_deref() == Some(mig_num.as_str()))
227        {
228            return Some(seg);
229        }
230
231        // 2. Tag fallback, excluding segments with a conflicting mig_number.
232        let tag = extract_segment_tag(&rule.segment_path)?;
233        return segments.iter().find(|s| {
234            s.tag == tag && !s.mig_number.as_ref().is_some_and(|m| m.as_str() != mig_num.as_str())
235        });
236    }
237
238    // No mig_number on rule — pure tag match.
239    let tag = extract_segment_tag(&rule.segment_path)?;
240    segments.iter().find(|s| s.tag == tag)
241}
242
243/// Extract the segment tag from a segment path.
244///
245/// `"SG4/DTM/C507/2380"` -> `"DTM"` (first non-SG component)
246/// `"BGM/1004"` -> `"BGM"`
247fn extract_segment_tag(segment_path: &str) -> Option<&str> {
248    segment_path
249        .split('/')
250        .find(|part| !part.starts_with("SG"))
251}
252
253/// Extract a field value from an assembled segment using element/component indices.
254fn extract_value<'a>(segment: &'a AssembledSegment, rule: &AhbFieldRule) -> Option<&'a str> {
255    let elem_idx = rule.element_index.unwrap_or(0);
256    let comp_idx = rule.component_index.unwrap_or(0);
257
258    let element = segment.elements.get(elem_idx)?;
259    let component = element.get(comp_idx)?;
260
261    if component.is_empty() {
262        None
263    } else {
264        Some(component.as_str())
265    }
266}
267
268/// Resolve assembled groups against grouped AHB rules, recursively.
269///
270/// `depth` is the number of group prefixes to strip from each rule's
271/// `segment_path` before classifying it as direct or child.
272fn resolve_groups<'a>(
273    assembled_groups: &'a [AssembledGroup],
274    group_rules: &HashMap<String, Vec<&'a AhbFieldRule>>,
275    depth: usize,
276) -> Vec<AhbGroupNode<'a>> {
277    let mut result = Vec::new();
278
279    for assembled_group in assembled_groups {
280        let rules = group_rules.get(&assembled_group.group_id);
281
282        for instance in &assembled_group.repetitions {
283            let node =
284                resolve_group_instance(&assembled_group.group_id, instance, rules, depth);
285            result.push(node);
286        }
287    }
288
289    result
290}
291
292/// Strip `n` leading group prefixes from a segment path.
293///
294/// `strip_n_groups("SG4/SG5/LOC/3225", 2)` -> `"LOC/3225"`
295fn strip_n_groups(path: &str, n: usize) -> &str {
296    let mut rest = path;
297    for _ in 0..n {
298        match rest.find('/') {
299            Some(idx) => rest = &rest[idx + 1..],
300            None => return rest,
301        }
302    }
303    rest
304}
305
306/// Resolve a single group instance.
307///
308/// `depth` is how many group prefixes have been consumed so far (0 for top-level groups).
309///
310/// Rules are filtered to this instance by `mig_number`: a rule only applies if
311/// its `mig_number` matches a segment in this instance (or a segment in a child
312/// group instance). This prevents rules for SG8/SEQ+Z79 from generating false
313/// missing-field errors against an SG8/SEQ+Z01 rep.
314fn resolve_group_instance<'a>(
315    group_id: &'a str,
316    instance: &'a AssembledGroupInstance,
317    rules: Option<&Vec<&'a AhbFieldRule>>,
318    depth: usize,
319) -> AhbGroupNode<'a> {
320    // We need to strip (depth + 1) group prefixes to get below this group level.
321    let strip_count = depth + 1;
322
323    // Use the variant's full set of mig_numbers (from MIG definition) to
324    // determine which rules belong to this instance. This includes numbers
325    // for segments that may be absent — so missing-field detection still works.
326    // Falls back to collecting from present segments if variant_mig_numbers is empty.
327    let variant_numbers: std::collections::HashSet<&str> = if !instance.variant_mig_numbers.is_empty() {
328        instance.variant_mig_numbers.iter().map(|s| s.as_str()).collect()
329    } else {
330        collect_instance_mig_numbers(instance)
331    };
332
333    let mut direct_rules: Vec<&'a AhbFieldRule> = Vec::new();
334    let mut child_group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
335    let mut ahb_status: Option<&'a str> = None;
336
337    if let Some(rules) = rules {
338        for rule in rules {
339            // Skip rules whose mig_number doesn't belong to this variant.
340            // Rules without mig_number pass through (tag-based fallback).
341            if let Some(ref rule_mig) = rule.mig_number {
342                if !variant_numbers.contains(rule_mig.as_str()) {
343                    continue;
344                }
345            }
346
347            // Strip all parent group prefixes plus this group to get the relative path.
348            let stripped = strip_n_groups(&rule.segment_path, strip_count);
349
350            if let Some(child_group_id) = extract_child_group(stripped) {
351                child_group_rules
352                    .entry(child_group_id.to_owned())
353                    .or_default()
354                    .push(rule);
355            } else {
356                direct_rules.push(rule);
357            }
358
359            // Capture the parent group AHB status if present.
360            if ahb_status.is_none() {
361                if let Some(ref status) = rule.parent_group_ahb_status {
362                    ahb_status = Some(status.as_str());
363                }
364            }
365        }
366    }
367
368    // Resolve direct fields against instance segments.
369    let fields = resolve_fields(&direct_rules, &instance.segments);
370
371    // Recurse into child groups one level deeper.
372    let children = resolve_groups(&instance.child_groups, &child_group_rules, strip_count);
373
374    AhbGroupNode {
375        group_id,
376        ahb_status,
377        fields,
378        children,
379    }
380}
381
382/// Collect all mig_numbers present in a group instance, including child groups recursively.
383fn collect_instance_mig_numbers(instance: &AssembledGroupInstance) -> std::collections::HashSet<&str> {
384    let mut numbers = std::collections::HashSet::new();
385    for seg in &instance.segments {
386        if let Some(ref num) = seg.mig_number {
387            numbers.insert(num.as_str());
388        }
389    }
390    for child_group in &instance.child_groups {
391        for child_instance in &child_group.repetitions {
392            numbers.extend(collect_instance_mig_numbers(child_instance));
393        }
394    }
395    numbers
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::validator::validate::{AhbFieldRule, AhbWorkflow};
402    use mig_assembly::assembler::{
403        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
404    };
405    use std::collections::{BTreeMap, HashMap};
406
407    fn empty_workflow() -> AhbWorkflow {
408        AhbWorkflow {
409            pruefidentifikator: "11001".to_string(),
410            description: String::new(),
411            communication_direction: None,
412            fields: vec![],
413            ub_definitions: HashMap::new(),
414        }
415    }
416
417    fn empty_tree() -> AssembledTree {
418        AssembledTree {
419            segments: vec![],
420            groups: vec![],
421            post_group_start: 0,
422            inter_group_segments: BTreeMap::new(),
423        }
424    }
425
426    fn make_segment(tag: &str, elements: Vec<Vec<&str>>, mig_number: Option<&str>) -> AssembledSegment {
427        AssembledSegment {
428            tag: tag.to_string(),
429            elements: elements
430                .into_iter()
431                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
432                .collect(),
433            mig_number: mig_number.map(|s| s.to_string()),
434        }
435    }
436
437    fn make_rule(
438        segment_path: &str,
439        name: &str,
440        ahb_status: &str,
441        mig_number: Option<&str>,
442        element_index: Option<usize>,
443        component_index: Option<usize>,
444    ) -> AhbFieldRule {
445        AhbFieldRule {
446            segment_path: segment_path.to_string(),
447            name: name.to_string(),
448            ahb_status: ahb_status.to_string(),
449            codes: vec![],
450            parent_group_ahb_status: None,
451            element_index,
452            component_index,
453            mig_number: mig_number.map(|s| s.to_string()),
454        }
455    }
456
457    #[test]
458    fn test_empty_workflow_empty_tree() {
459        let workflow = empty_workflow();
460        let tree = empty_tree();
461        let result = build_validated_tree(&workflow, &tree);
462
463        assert_eq!(result.pruefidentifikator, "11001");
464        assert!(result.root_fields.is_empty());
465        assert!(result.groups.is_empty());
466    }
467
468    #[test]
469    fn test_root_field_matches_root_segment() {
470        let mut workflow = empty_workflow();
471        workflow.fields.push(make_rule(
472            "BGM/C002/1001",
473            "Nachrichtentyp",
474            "X",
475            Some("0001"),
476            Some(0),
477            Some(0),
478        ));
479
480        let tree = AssembledTree {
481            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
482            groups: vec![],
483            post_group_start: 1,
484            inter_group_segments: BTreeMap::new(),
485        };
486
487        let result = build_validated_tree(&workflow, &tree);
488
489        assert_eq!(result.root_fields.len(), 1);
490        let node = &result.root_fields[0];
491        assert_eq!(node.value, Some("E01"));
492        assert!(node.segment_elements.is_some());
493        assert_eq!(node.rule.name, "Nachrichtentyp");
494    }
495
496    #[test]
497    fn test_sg4_dtm_mig_number_matching() {
498        // Two DTM segments with different mig_numbers.
499        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
500        let mut workflow = empty_workflow();
501
502        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
503        workflow.fields.push(make_rule(
504            "SG4/DTM/C507/2380",
505            "Eingangsdatum",
506            "X",
507            Some("0082"),
508            Some(1), // date value is element 1
509            Some(0),
510        ));
511
512        // Rule for DTM+137.
513        workflow.fields.push(make_rule(
514            "SG4/DTM/C507/2380",
515            "Dokumentendatum",
516            "X",
517            Some("0083"),
518            Some(1),
519            Some(0),
520        ));
521
522        let tree = AssembledTree {
523            segments: vec![],
524            groups: vec![AssembledGroup {
525                group_id: "SG4".to_string(),
526                repetitions: vec![AssembledGroupInstance {
527                    segments: vec![
528                        make_segment(
529                            "DTM",
530                            vec![vec!["92"], vec!["20260101"]],
531                            Some("0082"),
532                        ),
533                        make_segment(
534                            "DTM",
535                            vec![vec!["137"], vec!["20260401"]],
536                            Some("0083"),
537                        ),
538                    ],
539                    child_groups: vec![],
540                    entry_mig_number: None,
541                    variant_mig_numbers: vec![],
542                    skipped_segments: vec![],
543                }],
544            }],
545            post_group_start: 0,
546            inter_group_segments: BTreeMap::new(),
547        };
548
549        let result = build_validated_tree(&workflow, &tree);
550
551        assert_eq!(result.groups.len(), 1);
552        let sg4 = &result.groups[0];
553        assert_eq!(sg4.group_id, "SG4");
554        assert_eq!(sg4.fields.len(), 2);
555
556        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
557        let eingangsdatum = &sg4.fields[0];
558        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
559        assert_eq!(eingangsdatum.value, Some("20260101"));
560
561        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
562        let dokumentendatum = &sg4.fields[1];
563        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
564        assert_eq!(dokumentendatum.value, Some("20260401"));
565    }
566
567    #[test]
568    fn test_rule_filtered_to_correct_variant() {
569        // A rule with mig_number "0099" is filtered out from an instance
570        // that doesn't contain any segment with that mig_number.
571        // This prevents false missing-field errors for wrong SG8 variants.
572        let mut workflow = empty_workflow();
573        workflow.fields.push(make_rule(
574            "SG4/RFF/C506/1154",
575            "Referenz",
576            "X",
577            Some("0099"),
578            Some(0),
579            Some(1),
580        ));
581
582        let tree = AssembledTree {
583            segments: vec![],
584            groups: vec![AssembledGroup {
585                group_id: "SG4".to_string(),
586                repetitions: vec![AssembledGroupInstance {
587                    segments: vec![], // No segments — wrong variant
588                    child_groups: vec![],
589                    entry_mig_number: None,
590                    variant_mig_numbers: vec![],
591                    skipped_segments: vec![],
592                }],
593            }],
594            post_group_start: 0,
595            inter_group_segments: BTreeMap::new(),
596        };
597
598        let result = build_validated_tree(&workflow, &tree);
599        assert_eq!(result.groups.len(), 1);
600        // Rule is filtered out — its mig_number doesn't match this instance
601        assert_eq!(result.groups[0].fields.len(), 0);
602    }
603
604    #[test]
605    fn test_missing_segment_within_correct_variant() {
606        // A rule with mig_number "0099" IS included when the instance's
607        // variant_mig_numbers lists it — even though the segment is absent.
608        // This enables missing-field detection within the correct variant.
609        let mut workflow = empty_workflow();
610        // Entry segment rule (present)
611        workflow.fields.push(make_rule(
612            "SG4/SEQ/1229",
613            "Qualifier",
614            "X",
615            Some("0098"),
616            Some(0),
617            Some(0),
618        ));
619        // Second segment rule (absent — should report missing)
620        workflow.fields.push(make_rule(
621            "SG4/RFF/C506/1154",
622            "Referenz",
623            "X",
624            Some("0099"),
625            Some(0),
626            Some(1),
627        ));
628
629        let tree = AssembledTree {
630            segments: vec![],
631            groups: vec![AssembledGroup {
632                group_id: "SG4".to_string(),
633                repetitions: vec![AssembledGroupInstance {
634                    segments: vec![
635                        // Only the entry segment — RFF is missing
636                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
637                    ],
638                    child_groups: vec![],
639                    entry_mig_number: Some("0098".to_string()),
640                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
641                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
642                    skipped_segments: vec![],
643                }],
644            }],
645            post_group_start: 0,
646            inter_group_segments: BTreeMap::new(),
647        };
648
649        let result = build_validated_tree(&workflow, &tree);
650        assert_eq!(result.groups.len(), 1);
651        // Both rules match because variant_mig_numbers includes both
652        assert_eq!(result.groups[0].fields.len(), 2);
653        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
654        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
655        // RFF is missing — value is None (condition eval will report it)
656        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
657        assert_eq!(result.groups[0].fields[1].value, None);
658    }
659
660    #[test]
661    fn test_missing_group_variant_populates_unmatched_rules() {
662        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
663        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
664        // so validate_tree can report them as missing using flat logic.
665        let mut workflow = empty_workflow();
666
667        // NAD+MS qualifier rule
668        workflow.fields.push(make_rule(
669            "SG2/NAD/3035",
670            "MP-ID Absender Qualifier",
671            "X",
672            Some("0010"),
673            Some(0),
674            Some(0),
675        ));
676        // NAD+MS ID rule
677        workflow.fields.push(make_rule(
678            "SG2/NAD/C082/3039",
679            "MP-ID Absender",
680            "X",
681            Some("0010"),
682            Some(1),
683            Some(0),
684        ));
685        // NAD+MR qualifier rule
686        workflow.fields.push(make_rule(
687            "SG2/NAD/3035",
688            "MP-ID Empfänger Qualifier",
689            "X",
690            Some("0011"),
691            Some(0),
692            Some(0),
693        ));
694        // NAD+MR ID rule
695        workflow.fields.push(make_rule(
696            "SG2/NAD/C082/3039",
697            "MP-ID Empfänger",
698            "X",
699            Some("0011"),
700            Some(1),
701            Some(0),
702        ));
703
704        // Only NAD+MR present in assembled tree.
705        let tree = AssembledTree {
706            segments: vec![],
707            groups: vec![AssembledGroup {
708                group_id: "SG2".to_string(),
709                repetitions: vec![AssembledGroupInstance {
710                    segments: vec![make_segment(
711                        "NAD",
712                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
713                        Some("0011"),
714                    )],
715                    child_groups: vec![],
716                    entry_mig_number: Some("0011".to_string()),
717                    variant_mig_numbers: vec!["0011".to_string()],
718                    skipped_segments: vec![],
719                }],
720            }],
721            post_group_start: 0,
722            inter_group_segments: BTreeMap::new(),
723        };
724
725        let result = build_validated_tree(&workflow, &tree);
726
727        // Tree should have 1 SG2 group node (NAD+MR).
728        assert_eq!(result.groups.len(), 1);
729        assert_eq!(result.groups[0].fields.len(), 2);
730        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
731
732        // NAD+MS rules should be in unmatched_rules.
733        assert_eq!(
734            result.unmatched_rules.len(),
735            2,
736            "Expected 2 unmatched rules (NAD+MS), got {}",
737            result.unmatched_rules.len()
738        );
739        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
740        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
741    }
742
743    #[test]
744    fn test_entirely_absent_group_populates_unmatched_rules() {
745        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
746        let mut workflow = empty_workflow();
747        workflow.fields.push(make_rule(
748            "SG2/NAD/3035",
749            "MP-ID Absender Qualifier",
750            "X",
751            Some("0010"),
752            Some(0),
753            Some(0),
754        ));
755
756        let tree = empty_tree(); // No groups at all.
757
758        let result = build_validated_tree(&workflow, &tree);
759
760        // No tree nodes.
761        assert!(result.groups.is_empty());
762
763        // The rule should be in unmatched_rules.
764        assert_eq!(
765            result.unmatched_rules.len(),
766            1,
767            "Expected 1 unmatched rule, got {}",
768            result.unmatched_rules.len()
769        );
770        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
771    }
772
773    #[test]
774    fn test_fallback_to_tag_when_no_mig_number() {
775        let mut workflow = empty_workflow();
776        workflow.fields.push(make_rule(
777            "BGM/C002/1001",
778            "Nachrichtentyp",
779            "X",
780            None, // No mig_number — should fall back to tag.
781            Some(0),
782            Some(0),
783        ));
784
785        let tree = AssembledTree {
786            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
787            groups: vec![],
788            post_group_start: 1,
789            inter_group_segments: BTreeMap::new(),
790        };
791
792        let result = build_validated_tree(&workflow, &tree);
793        assert_eq!(result.root_fields.len(), 1);
794        assert_eq!(result.root_fields[0].value, Some("E01"));
795    }
796
797    #[test]
798    fn test_nested_child_groups() {
799        let mut workflow = empty_workflow();
800        // A rule in SG4/SG5.
801        workflow.fields.push(make_rule(
802            "SG4/SG5/LOC/C517/3225",
803            "Marktlokations-ID",
804            "X",
805            Some("0050"),
806            Some(0),
807            Some(0),
808        ));
809
810        let tree = AssembledTree {
811            segments: vec![],
812            groups: vec![AssembledGroup {
813                group_id: "SG4".to_string(),
814                repetitions: vec![AssembledGroupInstance {
815                    segments: vec![],
816                    entry_mig_number: None,
817                    child_groups: vec![AssembledGroup {
818                        group_id: "SG5".to_string(),
819                        repetitions: vec![AssembledGroupInstance {
820                            segments: vec![make_segment(
821                                "LOC",
822                                vec![vec!["DE00012345678"]],
823                                Some("0050"),
824                            )],
825                            child_groups: vec![],
826                            entry_mig_number: None,
827                            variant_mig_numbers: vec![],
828                            skipped_segments: vec![],
829                        }],
830                    }],
831                    variant_mig_numbers: vec![],
832                    skipped_segments: vec![],
833                }],
834            }],
835            post_group_start: 0,
836            inter_group_segments: BTreeMap::new(),
837        };
838
839        let result = build_validated_tree(&workflow, &tree);
840        assert_eq!(result.groups.len(), 1);
841        let sg4 = &result.groups[0];
842        assert_eq!(sg4.children.len(), 1);
843
844        let sg5 = &sg4.children[0];
845        assert_eq!(sg5.group_id, "SG5");
846        assert_eq!(sg5.fields.len(), 1);
847        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
848        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
849    }
850}