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::{BTreeMap, 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    /// Source segment counter of the matched [`AssembledSegment`], when known.
26    /// Used by issue construction to populate `segment_position`.
27    pub matched_segment_number: Option<u32>,
28}
29
30/// A segment group instance matched against EDIFACT data.
31#[derive(Debug)]
32pub struct AhbGroupNode<'a> {
33    /// Group ID (e.g., "SG4", "SG8").
34    pub group_id: &'a str,
35    /// Which repetition of `group_id` this is, among its siblings.
36    pub instance_index: usize,
37    /// AHB status of this group.
38    pub ahb_status: Option<&'a str>,
39    /// Fields in this group instance, resolved against EDIFACT segments.
40    pub fields: Vec<AhbNode<'a>>,
41    /// Child group instances.
42    pub children: Vec<AhbGroupNode<'a>>,
43}
44
45/// AHB workflow merged with assembled EDIFACT data.
46#[derive(Debug)]
47pub struct ValidatedTree<'a> {
48    /// The Pruefidentifikator.
49    pub pruefidentifikator: &'a str,
50    /// UB definitions for condition expansion.
51    pub ub_definitions: &'a BTreeMap<String, ConditionExpr>,
52    /// Root-level fields (outside segment groups).
53    pub root_fields: Vec<AhbNode<'a>>,
54    /// Top-level group instances.
55    pub groups: Vec<AhbGroupNode<'a>>,
56    /// Rules whose `mig_number` did not match any assembled group instance.
57    ///
58    /// These represent entirely-absent group variants (e.g., NAD+MS when only
59    /// NAD+MR is present). `validate_tree` evaluates these using the flat
60    /// `is_field_present`/`is_group_variant_absent` logic to correctly
61    /// distinguish mandatory-but-missing variants from optional-and-absent ones.
62    pub unmatched_rules: Vec<&'a AhbFieldRule>,
63}
64
65/// Build a [`ValidatedTree`] by merging an AHB workflow with an assembled EDIFACT tree.
66///
67/// The join key is `mig_number`: both [`AhbFieldRule::mig_number`] and
68/// [`AssembledSegment::mig_number`] carry the MIG `Number` attribute that
69/// uniquely identifies a segment variant.
70pub fn build_validated_tree<'a>(
71    workflow: &'a AhbWorkflow,
72    tree: &'a AssembledTree,
73) -> ValidatedTree<'a> {
74    // Partition AHB rules into root-level and per-group buckets.
75    let mut root_rules: Vec<&'a AhbFieldRule> = Vec::new();
76    // Map from top-level group prefix (e.g., "SG2", "SG4") to rules.
77    let mut group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
78
79    for rule in &workflow.fields {
80        match extract_top_group(&rule.segment_path) {
81            Some(group_id) => {
82                group_rules
83                    .entry(group_id.to_owned())
84                    .or_default()
85                    .push(rule);
86            }
87            None => {
88                root_rules.push(rule);
89            }
90        }
91    }
92
93    // Resolve root-level fields against root segments.
94    let root_fields = resolve_fields(&root_rules, &tree.segments);
95
96    // Resolve groups recursively (depth 0 = top-level groups).
97    let groups = resolve_groups(&tree.groups, &group_rules, 0);
98
99    // Find rules whose mig_number wasn't matched to any tree node.
100    // Collect all mig_numbers that appear in the assembled tree.
101    let mut matched_mig_numbers: std::collections::HashSet<&str> = std::collections::HashSet::new();
102    collect_matched_mig_numbers_from_groups(&groups, &mut matched_mig_numbers);
103    // Also include root segment mig_numbers.
104    for seg in &tree.segments {
105        if let Some(ref num) = seg.mig_number {
106            matched_mig_numbers.insert(num.as_str());
107        }
108    }
109
110    let unmatched_rules: Vec<&AhbFieldRule> = workflow
111        .fields
112        .iter()
113        .filter(|rule| {
114            rule.mig_number
115                .as_ref()
116                .is_some_and(|num| !matched_mig_numbers.contains(num.as_str()))
117        })
118        .collect();
119
120    ValidatedTree {
121        pruefidentifikator: &workflow.pruefidentifikator,
122        ub_definitions: &workflow.ub_definitions,
123        root_fields,
124        groups,
125        unmatched_rules,
126    }
127}
128
129/// Collect all mig_numbers from rules that were accepted into the tree.
130///
131/// Any rule in the tree — even with `value: None` — was accepted by the
132/// variant filter and is therefore "claimed". Rules NOT in this set are
133/// genuinely unmatched (their variant is entirely absent).
134fn collect_matched_mig_numbers_from_groups<'a>(
135    groups: &[AhbGroupNode<'a>],
136    out: &mut std::collections::HashSet<&'a str>,
137) {
138    for group in groups {
139        for node in &group.fields {
140            if let Some(ref num) = node.rule.mig_number {
141                out.insert(num.as_str());
142            }
143        }
144        collect_matched_mig_numbers_from_groups(&group.children, out);
145    }
146}
147
148/// Extract the top-level segment group from a segment path.
149///
150/// `"SG4/DTM/C507/2380"` -> `Some("SG4")`
151/// `"SG4/SG5/LOC/3225"` -> `Some("SG4")`
152/// `"BGM/1004"` -> `None`
153fn extract_top_group(segment_path: &str) -> Option<&str> {
154    let first = segment_path.split('/').next()?;
155    if first.starts_with("SG") {
156        Some(first)
157    } else {
158        None
159    }
160}
161
162/// Extract the child group prefix from a path that already had parent groups stripped.
163///
164/// `"SG5/LOC/3225"` -> `Some("SG5")`
165/// `"DTM/C507/2380"` -> `None`
166fn extract_child_group(stripped_path: &str) -> Option<&str> {
167    let first = stripped_path.split('/').next()?;
168    if first.starts_with("SG") {
169        Some(first)
170    } else {
171        None
172    }
173}
174
175/// Resolve AHB fields against a list of assembled segments.
176///
177/// For each rule, tries to find a matching segment by `mig_number` first,
178/// then falls back to segment tag matching. Rules that don't match any
179/// segment get `value: None` — condition evaluation decides if that's an error.
180fn resolve_fields<'a>(
181    rules: &[&'a AhbFieldRule],
182    segments: &'a [AssembledSegment],
183) -> Vec<AhbNode<'a>> {
184    rules
185        .iter()
186        .map(|rule| {
187            let matched_segment = find_segment(rule, segments);
188            let (value, elements, segment_number) = match matched_segment {
189                Some(seg) => {
190                    let val = extract_value(seg, rule);
191                    (val, Some(seg.elements.as_slice()), seg.segment_number)
192                }
193                None => {
194                    // Best-effort segment counter fallback: when find_segment
195                    // returns None because the expected qualifier is absent
196                    // (e.g., a Muss-313 format code missing from a DTM
197                    // segment that IS present), still surface the counter of
198                    // the same-tag segment in this scope so a CONTRL can
199                    // point to the right line. We don't borrow value/elements
200                    // because the variant truly is absent — only the counter
201                    // is meaningful.
202                    let fallback_num = extract_segment_tag(&rule.segment_path)
203                        .and_then(|tag| segments.iter().find(|s| s.tag == tag))
204                        .and_then(|s| s.segment_number);
205                    (None, None, fallback_num)
206                }
207            };
208            AhbNode {
209                rule,
210                value,
211                segment_elements: elements,
212                matched_segment_number: segment_number,
213            }
214        })
215        .collect()
216}
217
218/// Find the assembled segment matching an AHB field rule.
219///
220/// Matching strategy when the rule has a `mig_number`:
221/// 1. **Strict match**: find a segment with the exact `mig_number`.
222/// 2. **Tag fallback**: if no segment has that `mig_number`, fall back to tag
223///    matching — but ONLY against segments that don't have a *different*
224///    `mig_number` assigned. This prevents cross-matching between same-tag
225///    segments with known identities (e.g., DTM+92 rules must not match a
226///    DTM+93 segment that has `mig_number` "00024").
227/// 3. Segments with `mig_number: None` (unattributed) are eligible for the
228///    tag fallback, preserving backward compatibility with greedy assembly.
229fn find_segment<'a>(
230    rule: &AhbFieldRule,
231    segments: &'a [AssembledSegment],
232) -> Option<&'a AssembledSegment> {
233    let tag = extract_segment_tag(&rule.segment_path)?;
234
235    // Qualifier-aware match: when the rule's codes identify a segment
236    // variant (e.g. `STS/C601/9015` with codes=[Z32]), pick the segment
237    // whose value at the rule's position matches one of those codes.
238    // This overrides any mig_number that the assembler may have
239    // mis-assigned positionally to same-tag variants sharing a single
240    // group instance (e.g. MSCONS SG10 carries STS+Z32 *and* STS+Z40,
241    // and the assembler tags them by MIG slot order, not by qualifier).
242    //
243    // When *no* segment in the instance carries a matching qualifier,
244    // the outcome depends on how many same-tag segments are in view:
245    //   * 2+ same-tag segments → real contention between variants; the
246    //     rule's variant is genuinely absent from this instance, so
247    //     return None and let condition evaluation decide if that's an
248    //     error.
249    //   * 0–1 same-tag segment → no contention. Fall through to the
250    //     mig_number / tag match below to preserve behavior for merged
251    //     entry slots where multiple variants share one mig_number
252    //     (MSCONS SG2 collapses NAD+MS and NAD+MR onto mig=00013).
253    if !rule.codes.is_empty() {
254        let el_idx = rule.element_index.unwrap_or(0);
255        let comp_idx = rule.component_index.unwrap_or(0);
256        let expected: std::collections::HashSet<&str> =
257            rule.codes.iter().map(|c| c.value.as_str()).collect();
258        if let Some(seg) = segments.iter().find(|s| {
259            s.tag == tag
260                && s.elements
261                    .get(el_idx)
262                    .and_then(|e| e.get(comp_idx))
263                    .is_some_and(|v| expected.contains(v.as_str()))
264        }) {
265            return Some(seg);
266        }
267        let same_tag_count = segments.iter().filter(|s| s.tag == tag).count();
268        if same_tag_count > 1 {
269            return None;
270        }
271    }
272
273    if let Some(ref mig_num) = rule.mig_number {
274        // 1. Strict mig_number match.
275        if let Some(seg) = segments
276            .iter()
277            .find(|s| s.mig_number.as_deref() == Some(mig_num.as_str()))
278        {
279            return Some(seg);
280        }
281
282        // 2. Tag fallback, excluding segments with a conflicting mig_number.
283        return segments
284            .iter()
285            .find(|s| s.tag == tag && s.mig_number.as_ref().map_or(true, |m| m == mig_num));
286    }
287
288    // No mig_number on rule — pure tag match.
289    segments.iter().find(|s| s.tag == tag)
290}
291
292/// Extract the segment tag from a segment path.
293///
294/// `"SG4/DTM/C507/2380"` -> `"DTM"` (first non-SG component)
295/// `"BGM/1004"` -> `"BGM"`
296fn extract_segment_tag(segment_path: &str) -> Option<&str> {
297    segment_path.split('/').find(|part| !part.starts_with("SG"))
298}
299
300/// Extract a field value from an assembled segment using element/component indices.
301fn extract_value<'a>(segment: &'a AssembledSegment, rule: &AhbFieldRule) -> Option<&'a str> {
302    let elem_idx = rule.element_index.unwrap_or(0);
303    let comp_idx = rule.component_index.unwrap_or(0);
304
305    let element = segment.elements.get(elem_idx)?;
306    let component = element.get(comp_idx)?;
307
308    if component.is_empty() {
309        None
310    } else {
311        Some(component.as_str())
312    }
313}
314
315/// Resolve assembled groups against grouped AHB rules, recursively.
316///
317/// `depth` is the number of group prefixes to strip from each rule's
318/// `segment_path` before classifying it as direct or child.
319fn resolve_groups<'a>(
320    assembled_groups: &'a [AssembledGroup],
321    group_rules: &HashMap<String, Vec<&'a AhbFieldRule>>,
322    depth: usize,
323) -> Vec<AhbGroupNode<'a>> {
324    let mut result = Vec::new();
325
326    for assembled_group in assembled_groups {
327        let rules = group_rules.get(&assembled_group.group_id);
328
329        for (instance_index, instance) in assembled_group.repetitions.iter().enumerate() {
330            let mut node =
331                resolve_group_instance(&assembled_group.group_id, instance, rules, depth);
332            node.instance_index = instance_index;
333            result.push(node);
334        }
335    }
336
337    result
338}
339
340/// Strip `n` leading group prefixes from a segment path.
341///
342/// `strip_n_groups("SG4/SG5/LOC/3225", 2)` -> `"LOC/3225"`
343fn strip_n_groups(path: &str, n: usize) -> &str {
344    let mut rest = path;
345    for _ in 0..n {
346        match rest.find('/') {
347            Some(idx) => rest = &rest[idx + 1..],
348            None => return rest,
349        }
350    }
351    rest
352}
353
354/// Resolve a single group instance.
355///
356/// `depth` is how many group prefixes have been consumed so far (0 for top-level groups).
357///
358/// Rules are filtered to this instance by `mig_number`: a rule only applies if
359/// its `mig_number` matches a segment in this instance (or a segment in a child
360/// group instance). This prevents rules for SG8/SEQ+Z79 from generating false
361/// missing-field errors against an SG8/SEQ+Z01 rep.
362fn resolve_group_instance<'a>(
363    group_id: &'a str,
364    instance: &'a AssembledGroupInstance,
365    rules: Option<&Vec<&'a AhbFieldRule>>,
366    depth: usize,
367) -> AhbGroupNode<'a> {
368    // We need to strip (depth + 1) group prefixes to get below this group level.
369    let strip_count = depth + 1;
370
371    // Use the variant's full set of mig_numbers (from MIG definition) to
372    // determine which rules belong to this instance. This includes numbers
373    // for segments that may be absent — so missing-field detection still works.
374    // Falls back to collecting from present segments if variant_mig_numbers is empty.
375    let variant_numbers: std::collections::HashSet<&str> =
376        if !instance.variant_mig_numbers.is_empty() {
377            instance
378                .variant_mig_numbers
379                .iter()
380                .map(|s| s.as_str())
381                .collect()
382        } else {
383            collect_instance_mig_numbers(instance)
384        };
385
386    let mut direct_rules: Vec<&'a AhbFieldRule> = Vec::new();
387    let mut child_group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
388    let mut ahb_status: Option<&'a str> = None;
389
390    if let Some(rules) = rules {
391        for rule in rules {
392            // Skip rules whose mig_number doesn't belong to this variant.
393            // Rules without mig_number pass through (tag-based fallback).
394            if let Some(ref rule_mig) = rule.mig_number {
395                if !variant_numbers.contains(rule_mig.as_str()) {
396                    continue;
397                }
398            }
399
400            // Strip all parent group prefixes plus this group to get the relative path.
401            let stripped = strip_n_groups(&rule.segment_path, strip_count);
402
403            if let Some(child_group_id) = extract_child_group(stripped) {
404                child_group_rules
405                    .entry(child_group_id.to_owned())
406                    .or_default()
407                    .push(rule);
408            } else {
409                direct_rules.push(rule);
410            }
411
412            // Capture the parent group AHB status if present.
413            if ahb_status.is_none() {
414                if let Some(ref status) = rule.parent_group_ahb_status {
415                    ahb_status = Some(status.as_str());
416                }
417            }
418        }
419    }
420
421    // Resolve direct fields against instance segments.
422    let fields = resolve_fields(&direct_rules, &instance.segments);
423
424    // Recurse into child groups one level deeper.
425    let children = resolve_groups(&instance.child_groups, &child_group_rules, strip_count);
426
427    AhbGroupNode {
428        group_id,
429        instance_index: 0,
430        ahb_status,
431        fields,
432        children,
433    }
434}
435
436/// Collect all mig_numbers present in a group instance, including child groups recursively.
437fn collect_instance_mig_numbers(
438    instance: &AssembledGroupInstance,
439) -> std::collections::HashSet<&str> {
440    let mut numbers = std::collections::HashSet::new();
441    for seg in &instance.segments {
442        if let Some(ref num) = seg.mig_number {
443            numbers.insert(num.as_str());
444        }
445    }
446    for child_group in &instance.child_groups {
447        for child_instance in &child_group.repetitions {
448            numbers.extend(collect_instance_mig_numbers(child_instance));
449        }
450    }
451    numbers
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::validator::validate::{AhbFieldRule, AhbWorkflow};
458    use mig_assembly::assembler::{
459        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
460    };
461    use std::collections::BTreeMap;
462
463    fn empty_workflow() -> AhbWorkflow {
464        AhbWorkflow {
465            pruefidentifikator: "11001".to_string(),
466            description: String::new(),
467            communication_direction: None,
468            fields: vec![],
469            ub_definitions: BTreeMap::new(),
470        }
471    }
472
473    fn empty_tree() -> AssembledTree {
474        AssembledTree {
475            segments: vec![],
476            groups: vec![],
477            post_group_start: 0,
478            inter_group_segments: BTreeMap::new(),
479        }
480    }
481
482    fn make_segment(
483        tag: &str,
484        elements: Vec<Vec<&str>>,
485        mig_number: Option<&str>,
486    ) -> AssembledSegment {
487        AssembledSegment {
488            tag: tag.to_string(),
489            elements: elements
490                .into_iter()
491                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
492                .collect(),
493            mig_number: mig_number.map(|s| s.to_string()),
494            segment_number: None,
495        }
496    }
497
498    fn make_rule(
499        segment_path: &str,
500        name: &str,
501        ahb_status: &str,
502        mig_number: Option<&str>,
503        element_index: Option<usize>,
504        component_index: Option<usize>,
505    ) -> AhbFieldRule {
506        AhbFieldRule {
507            segment_path: segment_path.to_string(),
508            name: name.to_string(),
509            ahb_status: ahb_status.to_string(),
510            codes: vec![],
511            parent_group_ahb_status: None,
512            segment_ahb_status: None,
513            element_index,
514            component_index,
515            mig_number: mig_number.map(|s| s.to_string()),
516        }
517    }
518
519    #[test]
520    fn test_empty_workflow_empty_tree() {
521        let workflow = empty_workflow();
522        let tree = empty_tree();
523        let result = build_validated_tree(&workflow, &tree);
524
525        assert_eq!(result.pruefidentifikator, "11001");
526        assert!(result.root_fields.is_empty());
527        assert!(result.groups.is_empty());
528    }
529
530    #[test]
531    fn test_root_field_matches_root_segment() {
532        let mut workflow = empty_workflow();
533        workflow.fields.push(make_rule(
534            "BGM/C002/1001",
535            "Nachrichtentyp",
536            "X",
537            Some("0001"),
538            Some(0),
539            Some(0),
540        ));
541
542        let tree = AssembledTree {
543            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
544            groups: vec![],
545            post_group_start: 1,
546            inter_group_segments: BTreeMap::new(),
547        };
548
549        let result = build_validated_tree(&workflow, &tree);
550
551        assert_eq!(result.root_fields.len(), 1);
552        let node = &result.root_fields[0];
553        assert_eq!(node.value, Some("E01"));
554        assert!(node.segment_elements.is_some());
555        assert_eq!(node.rule.name, "Nachrichtentyp");
556    }
557
558    #[test]
559    fn test_sg4_dtm_mig_number_matching() {
560        // Two DTM segments with different mig_numbers.
561        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
562        let mut workflow = empty_workflow();
563
564        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
565        workflow.fields.push(make_rule(
566            "SG4/DTM/C507/2380",
567            "Eingangsdatum",
568            "X",
569            Some("0082"),
570            Some(1), // date value is element 1
571            Some(0),
572        ));
573
574        // Rule for DTM+137.
575        workflow.fields.push(make_rule(
576            "SG4/DTM/C507/2380",
577            "Dokumentendatum",
578            "X",
579            Some("0083"),
580            Some(1),
581            Some(0),
582        ));
583
584        let tree = AssembledTree {
585            segments: vec![],
586            groups: vec![AssembledGroup {
587                group_id: "SG4".to_string(),
588                repetitions: vec![AssembledGroupInstance {
589                    segments: vec![
590                        make_segment("DTM", vec![vec!["92"], vec!["20260101"]], Some("0082")),
591                        make_segment("DTM", vec![vec!["137"], vec!["20260401"]], Some("0083")),
592                    ],
593                    child_groups: vec![],
594                    entry_mig_number: None,
595                    variant_mig_numbers: vec![],
596                    skipped_segments: vec![],
597                    skipped_positions: Vec::new(),
598                }],
599            }],
600            post_group_start: 0,
601            inter_group_segments: BTreeMap::new(),
602        };
603
604        let result = build_validated_tree(&workflow, &tree);
605
606        assert_eq!(result.groups.len(), 1);
607        let sg4 = &result.groups[0];
608        assert_eq!(sg4.group_id, "SG4");
609        assert_eq!(sg4.fields.len(), 2);
610
611        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
612        let eingangsdatum = &sg4.fields[0];
613        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
614        assert_eq!(eingangsdatum.value, Some("20260101"));
615
616        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
617        let dokumentendatum = &sg4.fields[1];
618        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
619        assert_eq!(dokumentendatum.value, Some("20260401"));
620    }
621
622    #[test]
623    fn test_rule_filtered_to_correct_variant() {
624        // A rule with mig_number "0099" is filtered out from an instance
625        // that doesn't contain any segment with that mig_number.
626        // This prevents false missing-field errors for wrong SG8 variants.
627        let mut workflow = empty_workflow();
628        workflow.fields.push(make_rule(
629            "SG4/RFF/C506/1154",
630            "Referenz",
631            "X",
632            Some("0099"),
633            Some(0),
634            Some(1),
635        ));
636
637        let tree = AssembledTree {
638            segments: vec![],
639            groups: vec![AssembledGroup {
640                group_id: "SG4".to_string(),
641                repetitions: vec![AssembledGroupInstance {
642                    segments: vec![], // No segments — wrong variant
643                    child_groups: vec![],
644                    entry_mig_number: None,
645                    variant_mig_numbers: vec![],
646                    skipped_segments: vec![],
647                    skipped_positions: Vec::new(),
648                }],
649            }],
650            post_group_start: 0,
651            inter_group_segments: BTreeMap::new(),
652        };
653
654        let result = build_validated_tree(&workflow, &tree);
655        assert_eq!(result.groups.len(), 1);
656        // Rule is filtered out — its mig_number doesn't match this instance
657        assert_eq!(result.groups[0].fields.len(), 0);
658    }
659
660    #[test]
661    fn test_missing_segment_within_correct_variant() {
662        // A rule with mig_number "0099" IS included when the instance's
663        // variant_mig_numbers lists it — even though the segment is absent.
664        // This enables missing-field detection within the correct variant.
665        let mut workflow = empty_workflow();
666        // Entry segment rule (present)
667        workflow.fields.push(make_rule(
668            "SG4/SEQ/1229",
669            "Qualifier",
670            "X",
671            Some("0098"),
672            Some(0),
673            Some(0),
674        ));
675        // Second segment rule (absent — should report missing)
676        workflow.fields.push(make_rule(
677            "SG4/RFF/C506/1154",
678            "Referenz",
679            "X",
680            Some("0099"),
681            Some(0),
682            Some(1),
683        ));
684
685        let tree = AssembledTree {
686            segments: vec![],
687            groups: vec![AssembledGroup {
688                group_id: "SG4".to_string(),
689                repetitions: vec![AssembledGroupInstance {
690                    segments: vec![
691                        // Only the entry segment — RFF is missing
692                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
693                    ],
694                    child_groups: vec![],
695                    entry_mig_number: Some("0098".to_string()),
696                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
697                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
698                    skipped_segments: vec![],
699                    skipped_positions: Vec::new(),
700                }],
701            }],
702            post_group_start: 0,
703            inter_group_segments: BTreeMap::new(),
704        };
705
706        let result = build_validated_tree(&workflow, &tree);
707        assert_eq!(result.groups.len(), 1);
708        // Both rules match because variant_mig_numbers includes both
709        assert_eq!(result.groups[0].fields.len(), 2);
710        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
711        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
712        // RFF is missing — value is None (condition eval will report it)
713        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
714        assert_eq!(result.groups[0].fields[1].value, None);
715    }
716
717    #[test]
718    fn test_missing_group_variant_populates_unmatched_rules() {
719        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
720        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
721        // so validate_tree can report them as missing using flat logic.
722        let mut workflow = empty_workflow();
723
724        // NAD+MS qualifier rule
725        workflow.fields.push(make_rule(
726            "SG2/NAD/3035",
727            "MP-ID Absender Qualifier",
728            "X",
729            Some("0010"),
730            Some(0),
731            Some(0),
732        ));
733        // NAD+MS ID rule
734        workflow.fields.push(make_rule(
735            "SG2/NAD/C082/3039",
736            "MP-ID Absender",
737            "X",
738            Some("0010"),
739            Some(1),
740            Some(0),
741        ));
742        // NAD+MR qualifier rule
743        workflow.fields.push(make_rule(
744            "SG2/NAD/3035",
745            "MP-ID Empfänger Qualifier",
746            "X",
747            Some("0011"),
748            Some(0),
749            Some(0),
750        ));
751        // NAD+MR ID rule
752        workflow.fields.push(make_rule(
753            "SG2/NAD/C082/3039",
754            "MP-ID Empfänger",
755            "X",
756            Some("0011"),
757            Some(1),
758            Some(0),
759        ));
760
761        // Only NAD+MR present in assembled tree.
762        let tree = AssembledTree {
763            segments: vec![],
764            groups: vec![AssembledGroup {
765                group_id: "SG2".to_string(),
766                repetitions: vec![AssembledGroupInstance {
767                    segments: vec![make_segment(
768                        "NAD",
769                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
770                        Some("0011"),
771                    )],
772                    child_groups: vec![],
773                    entry_mig_number: Some("0011".to_string()),
774                    variant_mig_numbers: vec!["0011".to_string()],
775                    skipped_segments: vec![],
776                    skipped_positions: Vec::new(),
777                }],
778            }],
779            post_group_start: 0,
780            inter_group_segments: BTreeMap::new(),
781        };
782
783        let result = build_validated_tree(&workflow, &tree);
784
785        // Tree should have 1 SG2 group node (NAD+MR).
786        assert_eq!(result.groups.len(), 1);
787        assert_eq!(result.groups[0].fields.len(), 2);
788        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
789
790        // NAD+MS rules should be in unmatched_rules.
791        assert_eq!(
792            result.unmatched_rules.len(),
793            2,
794            "Expected 2 unmatched rules (NAD+MS), got {}",
795            result.unmatched_rules.len()
796        );
797        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
798        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
799    }
800
801    #[test]
802    fn test_entirely_absent_group_populates_unmatched_rules() {
803        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
804        let mut workflow = empty_workflow();
805        workflow.fields.push(make_rule(
806            "SG2/NAD/3035",
807            "MP-ID Absender Qualifier",
808            "X",
809            Some("0010"),
810            Some(0),
811            Some(0),
812        ));
813
814        let tree = empty_tree(); // No groups at all.
815
816        let result = build_validated_tree(&workflow, &tree);
817
818        // No tree nodes.
819        assert!(result.groups.is_empty());
820
821        // The rule should be in unmatched_rules.
822        assert_eq!(
823            result.unmatched_rules.len(),
824            1,
825            "Expected 1 unmatched rule, got {}",
826            result.unmatched_rules.len()
827        );
828        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
829    }
830
831    #[test]
832    fn test_fallback_to_tag_when_no_mig_number() {
833        let mut workflow = empty_workflow();
834        workflow.fields.push(make_rule(
835            "BGM/C002/1001",
836            "Nachrichtentyp",
837            "X",
838            None, // No mig_number — should fall back to tag.
839            Some(0),
840            Some(0),
841        ));
842
843        let tree = AssembledTree {
844            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
845            groups: vec![],
846            post_group_start: 1,
847            inter_group_segments: BTreeMap::new(),
848        };
849
850        let result = build_validated_tree(&workflow, &tree);
851        assert_eq!(result.root_fields.len(), 1);
852        assert_eq!(result.root_fields[0].value, Some("E01"));
853    }
854
855    #[test]
856    fn test_nested_child_groups() {
857        let mut workflow = empty_workflow();
858        // A rule in SG4/SG5.
859        workflow.fields.push(make_rule(
860            "SG4/SG5/LOC/C517/3225",
861            "Marktlokations-ID",
862            "X",
863            Some("0050"),
864            Some(0),
865            Some(0),
866        ));
867
868        let tree = AssembledTree {
869            segments: vec![],
870            groups: vec![AssembledGroup {
871                group_id: "SG4".to_string(),
872                repetitions: vec![AssembledGroupInstance {
873                    segments: vec![],
874                    entry_mig_number: None,
875                    child_groups: vec![AssembledGroup {
876                        group_id: "SG5".to_string(),
877                        repetitions: vec![AssembledGroupInstance {
878                            segments: vec![make_segment(
879                                "LOC",
880                                vec![vec!["DE00012345678"]],
881                                Some("0050"),
882                            )],
883                            child_groups: vec![],
884                            entry_mig_number: None,
885                            variant_mig_numbers: vec![],
886                            skipped_segments: vec![],
887                            skipped_positions: Vec::new(),
888                        }],
889                    }],
890                    variant_mig_numbers: vec![],
891                    skipped_segments: vec![],
892                    skipped_positions: Vec::new(),
893                }],
894            }],
895            post_group_start: 0,
896            inter_group_segments: BTreeMap::new(),
897        };
898
899        let result = build_validated_tree(&workflow, &tree);
900        assert_eq!(result.groups.len(), 1);
901        let sg4 = &result.groups[0];
902        assert_eq!(sg4.children.len(), 1);
903
904        let sg5 = &sg4.children[0];
905        assert_eq!(sg5.group_id, "SG5");
906        assert_eq!(sg5.fields.len(), 1);
907        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
908        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
909    }
910
911    #[test]
912    fn test_qualifier_aware_segment_matching_same_tag_variants() {
913        // MSCONS SG10 has four STS variants (9015=Z33, Z32, Z34, Z40) with
914        // mig_numbers 00035..00038. The assembler matches same-tag MIG slots
915        // positionally, so when input contains STS+Z32++Z92 and STS+Z40++Z75,
916        // the segments end up tagged with mig=00035 and mig=00036 (the first
917        // two MIG slots) instead of mig=00036 and mig=00038.
918        //
919        // `find_segment` must therefore prefer a segment whose qualifier value
920        // matches the rule's single-code constraint over the assembler's
921        // positional mig_number. Otherwise:
922        //   * rule for Z33 (mig 00035) attaches to the Z32 segment and looks
923        //     "present" with the wrong value, suppressing the genuine absence;
924        //   * rule for Z40 (mig 00038) finds no segment and reports the Z40
925        //     STS as "missing" even though it is present in the input.
926        let mut workflow = empty_workflow();
927
928        // Z33 variant — not present in input, must resolve to None.
929        let mut z33 = make_rule(
930            "SG10/STS/C601/9015",
931            "Statuskategorie Z33",
932            "X",
933            Some("00035"),
934            Some(0),
935            Some(0),
936        );
937        z33.codes = vec![super::super::validate::AhbCodeRule {
938            value: "Z33".into(),
939            description: String::new(),
940            ahb_status: "X".into(),
941        }];
942        workflow.fields.push(z33);
943
944        // Z32 variant — present in input.
945        let mut z32 = make_rule(
946            "SG10/STS/C601/9015",
947            "Statuskategorie Z32",
948            "X",
949            Some("00036"),
950            Some(0),
951            Some(0),
952        );
953        z32.codes = vec![super::super::validate::AhbCodeRule {
954            value: "Z32".into(),
955            description: String::new(),
956            ahb_status: "X".into(),
957        }];
958        workflow.fields.push(z32);
959
960        // Z40 variant — present in input.
961        let mut z40 = make_rule(
962            "SG10/STS/C601/9015",
963            "Statuskategorie Z40",
964            "X",
965            Some("00038"),
966            Some(0),
967            Some(0),
968        );
969        z40.codes = vec![super::super::validate::AhbCodeRule {
970            value: "Z40".into(),
971            description: String::new(),
972            ahb_status: "X".into(),
973        }];
974        workflow.fields.push(z40);
975
976        // Assembler assigned mig=00035 to the Z32 segment and mig=00036 to the Z40
977        // segment (positional), mimicking the real MSCONS 13025 pipeline output.
978        let tree = AssembledTree {
979            segments: vec![],
980            groups: vec![AssembledGroup {
981                group_id: "SG10".to_string(),
982                repetitions: vec![AssembledGroupInstance {
983                    segments: vec![
984                        make_segment("STS", vec![vec!["Z32"], vec![], vec!["Z92"]], Some("00035")),
985                        make_segment("STS", vec![vec!["Z40"], vec![], vec!["Z75"]], Some("00036")),
986                    ],
987                    child_groups: vec![],
988                    entry_mig_number: None,
989                    variant_mig_numbers: vec![
990                        "00035".into(),
991                        "00036".into(),
992                        "00037".into(),
993                        "00038".into(),
994                    ],
995                    skipped_segments: vec![],
996                    skipped_positions: Vec::new(),
997                }],
998            }],
999            post_group_start: 0,
1000            inter_group_segments: BTreeMap::new(),
1001        };
1002
1003        let result = build_validated_tree(&workflow, &tree);
1004        assert_eq!(result.groups.len(), 1);
1005        let sg10 = &result.groups[0];
1006        assert_eq!(
1007            sg10.fields.len(),
1008            3,
1009            "all three rules should pass the variant filter"
1010        );
1011
1012        let by_name = |name: &str| sg10.fields.iter().find(|f| f.rule.name == name).unwrap();
1013
1014        // Z33 is genuinely absent — no STS in the input has 9015=Z33.
1015        assert_eq!(
1016            by_name("Statuskategorie Z33").value,
1017            None,
1018            "Z33 has no matching STS in the input; must not silently pick up Z32's segment"
1019        );
1020
1021        // Z32 must resolve to the STS segment whose 9015 is Z32,
1022        // regardless of the assembler's mis-assigned mig_number.
1023        assert_eq!(
1024            by_name("Statuskategorie Z32").value,
1025            Some("Z32"),
1026            "rule for Z32 must attach to the STS segment with qualifier Z32"
1027        );
1028
1029        // Z40 must resolve to the STS segment whose 9015 is Z40.
1030        assert_eq!(
1031            by_name("Statuskategorie Z40").value,
1032            Some("Z40"),
1033            "rule for Z40 must attach to the STS segment with qualifier Z40"
1034        );
1035    }
1036}