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