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, HashSet};
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    /// Every rule of this instance's variant, its child groups' included.
44    pub variant_rules: Vec<&'a AhbFieldRule>,
45    /// The rules of child group variants this instance lists but lacks: each
46    /// such variant's own segments, not those of groups nested in it. Only
47    /// variants told apart by their entry qualifier are counted.
48    pub absent_children: Vec<&'a AhbFieldRule>,
49}
50
51/// AHB workflow merged with assembled EDIFACT data.
52#[derive(Debug)]
53pub struct ValidatedTree<'a> {
54    /// The Pruefidentifikator.
55    pub pruefidentifikator: &'a str,
56    /// UB definitions for condition expansion.
57    pub ub_definitions: &'a BTreeMap<String, ConditionExpr>,
58    /// The packages' conditions, for package cardinality.
59    pub package_conditions: &'a BTreeMap<u32, ConditionExpr>,
60    /// Root-level fields (outside segment groups).
61    pub root_fields: Vec<AhbNode<'a>>,
62    /// Top-level group instances.
63    pub groups: Vec<AhbGroupNode<'a>>,
64    /// Rules whose `mig_number` did not match any assembled group instance.
65    ///
66    /// These represent entirely-absent group variants (e.g., NAD+MS when only
67    /// NAD+MR is present). `validate_tree` evaluates these using the flat
68    /// `is_field_present`/`is_group_variant_absent` logic to correctly
69    /// distinguish mandatory-but-missing variants from optional-and-absent ones.
70    pub unmatched_rules: Vec<&'a AhbFieldRule>,
71}
72
73/// Build a [`ValidatedTree`] by merging an AHB workflow with an assembled EDIFACT tree.
74///
75/// The join key is `mig_number`: both [`AhbFieldRule::mig_number`] and
76/// [`AssembledSegment::mig_number`] carry the MIG `Number` attribute that
77/// uniquely identifies a segment variant.
78pub fn build_validated_tree<'a>(
79    workflow: &'a AhbWorkflow,
80    tree: &'a AssembledTree,
81) -> ValidatedTree<'a> {
82    // Partition AHB rules into root-level and per-group buckets.
83    let mut root_rules: Vec<&'a AhbFieldRule> = Vec::new();
84    // Map from top-level group prefix (e.g., "SG2", "SG4") to rules.
85    let mut group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
86
87    for rule in &workflow.fields {
88        match extract_top_group(&rule.segment_path) {
89            Some(group_id) => {
90                group_rules
91                    .entry(group_id.to_owned())
92                    .or_default()
93                    .push(rule);
94            }
95            None => {
96                root_rules.push(rule);
97            }
98        }
99    }
100
101    // Resolve root-level fields against root segments.
102    let root_fields = resolve_fields(&root_rules, &tree.segments, None);
103
104    // Resolve groups recursively (depth 0 = top-level groups).
105    let groups = resolve_groups(&tree.groups, &group_rules, 0, false);
106
107    // Find rules whose mig_number wasn't matched to any tree node.
108    // Collect all mig_numbers that appear in the assembled tree.
109    let mut matched_mig_numbers: std::collections::HashSet<&str> = std::collections::HashSet::new();
110    collect_matched_mig_numbers_from_groups(&groups, &mut matched_mig_numbers);
111    // Also include root segment mig_numbers.
112    for seg in &tree.segments {
113        if let Some(ref num) = seg.mig_number {
114            matched_mig_numbers.insert(num.as_str());
115        }
116    }
117
118    let unmatched_rules: Vec<&AhbFieldRule> = workflow
119        .fields
120        .iter()
121        .filter(|rule| {
122            rule.mig_number
123                .as_ref()
124                .is_some_and(|num| !matched_mig_numbers.contains(num.as_str()))
125        })
126        .collect();
127
128    ValidatedTree {
129        pruefidentifikator: &workflow.pruefidentifikator,
130        ub_definitions: &workflow.ub_definitions,
131        package_conditions: &workflow.package_conditions,
132        root_fields,
133        groups,
134        unmatched_rules,
135    }
136}
137
138/// Collect all mig_numbers from rules that were accepted into the tree.
139///
140/// Any rule in the tree — even with `value: None` — was accepted by the
141/// variant filter and is therefore "claimed". Rules NOT in this set are
142/// genuinely unmatched (their variant is entirely absent).
143fn collect_matched_mig_numbers_from_groups<'a>(
144    groups: &[AhbGroupNode<'a>],
145    out: &mut std::collections::HashSet<&'a str>,
146) {
147    for group in groups {
148        for node in &group.fields {
149            if let Some(ref num) = node.rule.mig_number {
150                out.insert(num.as_str());
151            }
152        }
153        collect_matched_mig_numbers_from_groups(&group.children, out);
154    }
155}
156
157/// The variant of a merged group `instance` belongs to: its entry rule's MIG
158/// number and the MIG numbers of the variant (up to the next variant's
159/// entry). The variants are told apart at the entry segment's position where
160/// several rules list disjoint codes; `None` when there is no such position
161/// or the instance's value names none of them.
162fn merged_variant<'a>(
163    rules: &[&'a AhbFieldRule],
164    instance: &AssembledGroupInstance,
165) -> Option<(&'a str, std::ops::Range<u32>)> {
166    let entry = instance.segments.first()?;
167    // Only when the first segment is the group's entry: the assembler records
168    // the (first variant's) entry MIG number, and a rule of that number is on
169    // this tag.
170    // Under a merged parent the number recorded is that of the parent's
171    // first variant's group (ORDERS 17121: the SG30 of SG29 LIN+Z16, not of
172    // LIN+Z54); then the first rule of these, in MIG order, is on this tag.
173    let recorded = instance.entry_mig_number.as_deref()?;
174    let first_tag = rules
175        .iter()
176        .filter_map(|r| Some((r.mig_number.as_deref()?.parse::<u32>().ok()?, *r)))
177        .min_by_key(|(n, _)| *n)
178        .and_then(|(_, r)| extract_segment_tag(&r.segment_path));
179    if !rules.iter().any(|r| {
180        r.mig_number.as_deref() == Some(recorded)
181            && extract_segment_tag(&r.segment_path) == Some(entry.tag.as_str())
182    }) && first_tag != Some(entry.tag.as_str())
183    {
184        return None;
185    }
186    // A merged variant's entry segment occurs once per instance — the next
187    // one starts the next instance. Several same-tag segments are slots of one
188    // variant (MSCONS SG10 STS+Z32, STS+Z40), told apart segment by segment.
189    if instance
190        .segments
191        .iter()
192        .filter(|s| s.tag == entry.tag)
193        .count()
194        != 1
195    {
196        return None;
197    }
198    let number = |r: &AhbFieldRule| r.mig_number.as_deref()?.parse::<u32>().ok();
199    let mut positions: Vec<(usize, usize)> = Vec::new();
200    for r in rules {
201        let pos = (r.element_index.unwrap_or(0), r.component_index.unwrap_or(0));
202        if extract_segment_tag(&r.segment_path) == Some(entry.tag.as_str())
203            && !r.codes.is_empty()
204            && !positions.contains(&pos)
205        {
206            positions.push(pos);
207        }
208    }
209    // The MIG numbers of every entry-tag rule: where a variant starts.
210    let all_entry_numbers: std::collections::BTreeSet<u32> = rules
211        .iter()
212        .filter(|r| extract_segment_tag(&r.segment_path) == Some(entry.tag.as_str()))
213        .filter_map(|r| number(r))
214        .collect();
215    // First a position every variant codes; failing that, one where the
216    // instance carries the code of exactly one variant, its range ending at
217    // the next variant's entry whether or not that variant codes the
218    // position (ORDERS 17121's SG30: the Zählzeit codes DE7059, CCI+++ZA8/
219    // ZA7/ZA9 code DE7037).
220    for every_variant in [true, false] {
221        // A group of one variant is no merged group (55003's SG4).
222        if !every_variant && all_entry_numbers.len() < 2 {
223            break;
224        }
225        if let Some(found) =
226            variant_at_positions(rules, entry, &positions, &all_entry_numbers, every_variant)
227        {
228            return Some(found);
229        }
230    }
231    None
232}
233
234/// [`merged_variant`] at the first of `positions` that tells the instance's
235/// entry segment apart: the variant whose disjoint codes there hold its
236/// value. With `every_variant`, only a position every entry-tag rule codes.
237fn variant_at_positions<'a>(
238    rules: &[&'a AhbFieldRule],
239    entry: &mig_assembly::assembler::AssembledSegment,
240    positions: &[(usize, usize)],
241    all_entry_numbers: &std::collections::BTreeSet<u32>,
242    every_variant: bool,
243) -> Option<(&'a str, std::ops::Range<u32>)> {
244    let number = |r: &AhbFieldRule| r.mig_number.as_deref()?.parse::<u32>().ok();
245    for &(element, component) in positions {
246        // (MIG number, rule) of every entry-tag rule coding this position.
247        let mut entries: Vec<(u32, &'a AhbFieldRule)> = rules
248            .iter()
249            .filter(|r| {
250                extract_segment_tag(&r.segment_path) == Some(entry.tag.as_str())
251                    && r.element_index.unwrap_or(0) == element
252                    && r.component_index.unwrap_or(0) == component
253                    && !r.codes.is_empty()
254            })
255            .filter_map(|r| Some((number(r)?, *r)))
256            .collect();
257        entries.sort_by_key(|(n, _)| *n);
258        entries.dedup_by_key(|(n, _)| *n);
259        // Every entry-tag segment of the group told apart here; else a
260        // variant without a code at this position must not fall into its
261        // neighbour's range (UTILMD SG10: `CCI+Z17` has no DE7037), so the
262        // range ends at the next variant of all.
263        if every_variant && entries.len() != all_entry_numbers.len() {
264            continue;
265        }
266        let disjoint = (entries.len() >= 2 || !every_variant && !entries.is_empty())
267            && entries.iter().enumerate().all(|(i, (_, a))| {
268                entries[i + 1..].iter().all(|(_, b)| {
269                    !a.codes
270                        .iter()
271                        .any(|c| b.codes.iter().any(|d| d.value == c.value))
272                })
273            });
274        if !disjoint {
275            continue;
276        }
277        let Some(value) = entry.elements.get(element).and_then(|e| e.get(component)) else {
278            if every_variant {
279                return None;
280            }
281            continue;
282        };
283        let Some(at) = entries
284            .iter()
285            .position(|(_, r)| r.codes.iter().any(|c| &c.value == value))
286        else {
287            if every_variant {
288                return None;
289            }
290            continue;
291        };
292        let start = entries[at].0;
293        let end = all_entry_numbers
294            .range(start + 1..)
295            .next()
296            .copied()
297            .unwrap_or(u32::MAX);
298        return Some((entries[at].1.mig_number.as_deref()?, start..end));
299    }
300    None
301}
302
303/// Extract the top-level segment group from a segment path.
304///
305/// `"SG4/DTM/C507/2380"` -> `Some("SG4")`
306/// `"SG4/SG5/LOC/3225"` -> `Some("SG4")`
307/// `"BGM/1004"` -> `None`
308fn extract_top_group(segment_path: &str) -> Option<&str> {
309    let first = segment_path.split('/').next()?;
310    if first.starts_with("SG") {
311        Some(first)
312    } else {
313        None
314    }
315}
316
317/// Extract the child group prefix from a path that already had parent groups stripped.
318///
319/// `"SG5/LOC/3225"` -> `Some("SG5")`
320/// `"DTM/C507/2380"` -> `None`
321fn extract_child_group(stripped_path: &str) -> Option<&str> {
322    let first = stripped_path.split('/').next()?;
323    if first.starts_with("SG") {
324        Some(first)
325    } else {
326        None
327    }
328}
329
330/// Resolve AHB fields against a list of assembled segments.
331///
332/// For each rule, tries to find a matching segment by `mig_number` first,
333/// then falls back to segment tag matching. Rules that don't match any
334/// segment get `value: None` — condition evaluation decides if that's an error.
335fn resolve_fields<'a>(
336    rules: &[&'a AhbFieldRule],
337    segments: &'a [AssembledSegment],
338    numbers: Option<&[Option<String>]>,
339) -> Vec<AhbNode<'a>> {
340    // The qualifiers of each MIG segment slot, from its rules that list
341    // codes at the qualifier position (element 0, component 0): `00004` →
342    // {137}. A rule of the same slot without codes (DE2380) finds its
343    // segment by them, not by the MIG number the assembler gave positionally.
344    let mut qualifiers: HashMap<(&str, &str), HashSet<&str>> = HashMap::new();
345    for rule in rules {
346        if rule.element_index.unwrap_or(0) != 0 || rule.component_index.unwrap_or(0) != 0 {
347            continue;
348        }
349        let (Some(tag), Some(number)) = (
350            extract_segment_tag(&rule.segment_path),
351            rule.mig_number.as_deref(),
352        ) else {
353            continue;
354        };
355        if !rule.codes.is_empty() {
356            qualifiers
357                .entry((tag, number))
358                .or_default()
359                .extend(rule.codes.iter().map(|c| c.value.as_str()));
360        }
361    }
362    rules
363        .iter()
364        .map(|rule| {
365            let slot_qualifiers = match (
366                rule.codes.is_empty(),
367                extract_segment_tag(&rule.segment_path),
368                rule.mig_number.as_deref(),
369            ) {
370                (true, Some(tag), Some(number)) => qualifiers.get(&(tag, number)),
371                _ => None,
372            };
373            let matched_segment = match slot_qualifiers {
374                Some(q) => find_segment_by_qualifier(rule, segments, q)
375                    .unwrap_or_else(|| find_segment(rule, segments, numbers)),
376                None => find_segment(rule, segments, numbers),
377            };
378            let (value, elements, segment_number) = match matched_segment {
379                Some(seg) => {
380                    let val = extract_value(seg, rule);
381                    (val, Some(seg.elements.as_slice()), seg.segment_number)
382                }
383                None => {
384                    // Best-effort segment counter fallback: when find_segment
385                    // returns None because the expected qualifier is absent
386                    // (e.g., a Muss-313 format code missing from a DTM
387                    // segment that IS present), still surface the counter of
388                    // the same-tag segment in this scope so a CONTRL can
389                    // point to the right line. We don't borrow value/elements
390                    // because the variant truly is absent — only the counter
391                    // is meaningful.
392                    let fallback_num = extract_segment_tag(&rule.segment_path)
393                        .and_then(|tag| segments.iter().find(|s| s.tag == tag))
394                        .and_then(|s| s.segment_number);
395                    (None, None, fallback_num)
396                }
397            };
398            AhbNode {
399                rule,
400                value,
401                segment_elements: elements,
402                matched_segment_number: segment_number,
403            }
404        })
405        .collect()
406}
407
408/// Find the assembled segment matching an AHB field rule.
409///
410/// Matching strategy when the rule has a `mig_number`:
411/// 1. **Strict match**: find a segment with the exact `mig_number`.
412/// 2. **Tag fallback**: if no segment has that `mig_number`, fall back to tag
413///    matching — but ONLY against segments that don't have a *different*
414///    `mig_number` assigned. This prevents cross-matching between same-tag
415///    segments with known identities (e.g., DTM+92 rules must not match a
416///    DTM+93 segment that has `mig_number` "00024").
417/// 3. Segments with `mig_number: None` (unattributed) are eligible for the
418///    tag fallback, preserving backward compatibility with greedy assembly.
419fn find_segment<'a>(
420    rule: &AhbFieldRule,
421    segments: &'a [AssembledSegment],
422    numbers: Option<&[Option<String>]>,
423) -> Option<&'a AssembledSegment> {
424    // A segment's MIG number: its own, or the one `numbers` assigns it.
425    let number_of = |i: usize, s: &'a AssembledSegment| -> Option<&str> {
426        match numbers {
427            Some(n) => n.get(i).and_then(|n| n.as_deref()),
428            None => s.mig_number.as_deref(),
429        }
430    };
431    let tag = extract_segment_tag(&rule.segment_path)?;
432
433    // Qualifier-aware match: when the rule's codes identify a segment
434    // variant (e.g. `STS/C601/9015` with codes=[Z32]), pick the segment
435    // whose value at the rule's position matches one of those codes.
436    // This overrides any mig_number that the assembler may have
437    // mis-assigned positionally to same-tag variants sharing a single
438    // group instance (e.g. MSCONS SG10 carries STS+Z32 *and* STS+Z40,
439    // and the assembler tags them by MIG slot order, not by qualifier).
440    //
441    // When *no* segment in the instance carries a matching qualifier,
442    // the outcome depends on how many same-tag segments are in view:
443    //   * 2+ same-tag segments → real contention between variants; the
444    //     rule's variant is genuinely absent from this instance, so
445    //     return None and let condition evaluation decide if that's an
446    //     error.
447    //   * 0–1 same-tag segment → no contention. Fall through to the
448    //     mig_number / tag match below to preserve behavior for merged
449    //     entry slots where multiple variants share one mig_number
450    //     (MSCONS SG2 collapses NAD+MS and NAD+MR onto mig=00013).
451    if !rule.codes.is_empty() {
452        let el_idx = rule.element_index.unwrap_or(0);
453        let comp_idx = rule.component_index.unwrap_or(0);
454        let expected: std::collections::HashSet<&str> =
455            rule.codes.iter().map(|c| c.value.as_str()).collect();
456        if let Some(seg) = segments.iter().find(|s| {
457            s.tag == tag
458                && s.elements
459                    .get(el_idx)
460                    .and_then(|e| e.get(comp_idx))
461                    .is_some_and(|v| expected.contains(v.as_str()))
462        }) {
463            return Some(seg);
464        }
465        let same_tag_count = segments.iter().filter(|s| s.tag == tag).count();
466        if same_tag_count > 1 {
467            return None;
468        }
469        // One same-tag segment that is not the group's entry and carries a
470        // different qualifier is another slot's (QUOTES 15001 SG27: a
471        // position with only `DTM+Z03` Baujahr has no `DTM+Z04`
472        // Eichgültigkeit). Only an entry segment may stand for a merged
473        // variant whose qualifier it does not carry.
474        let other_slot = segments.iter().enumerate().any(|(i, s)| {
475            i > 0
476                && s.tag == tag
477                && s.elements
478                    .get(el_idx)
479                    .and_then(|e| e.get(comp_idx))
480                    .is_some_and(|v| !v.is_empty() && !expected.contains(v.as_str()))
481        });
482        if other_slot {
483            return None;
484        }
485    }
486
487    if let Some(mig_num) = rule.mig_number.as_ref() {
488        // 1. Strict mig_number match.
489        if let Some((_, seg)) = segments
490            .iter()
491            .enumerate()
492            .find(|(i, s)| number_of(*i, s) == Some(mig_num.as_str()))
493        {
494            return Some(seg);
495        }
496
497        // 2. Tag fallback, excluding segments with a conflicting mig_number.
498        return segments
499            .iter()
500            .enumerate()
501            .find(|(i, s)| s.tag == tag && number_of(*i, s).map_or(true, |m| m == mig_num))
502            .map(|(_, s)| s);
503    }
504
505    // No mig_number on rule — pure tag match.
506    segments.iter().find(|s| s.tag == tag)
507}
508
509/// The segment of `rule`'s slot among several of its tag, by the slot's
510/// `qualifiers`: PRICAT 27001 sends DTM+137 before DTM+492, against MIG
511/// order, and the assembler numbers them by position, so DTM+492 took
512/// DTM+137's rule (`X [931] [494]`). `None` (decide as before) with fewer
513/// than two segments of the tag; `Some(None)` when none carries a qualifier
514/// of the slot, which is then absent.
515fn find_segment_by_qualifier<'a>(
516    rule: &AhbFieldRule,
517    segments: &'a [AssembledSegment],
518    qualifiers: &HashSet<&str>,
519) -> Option<Option<&'a AssembledSegment>> {
520    let tag = extract_segment_tag(&rule.segment_path)?;
521    let same_tag = || segments.iter().filter(|s| s.tag == tag);
522    if same_tag().count() < 2 {
523        return None;
524    }
525    Some(same_tag().find(|s| {
526        s.elements
527            .first()
528            .and_then(|e| e.first())
529            .is_some_and(|v| qualifiers.contains(v.as_str()))
530    }))
531}
532
533/// Extract the segment tag from a segment path.
534///
535/// `"SG4/DTM/C507/2380"` -> `"DTM"` (first non-SG component)
536/// `"BGM/1004"` -> `"BGM"`
537fn extract_segment_tag(segment_path: &str) -> Option<&str> {
538    segment_path.split('/').find(|part| !part.starts_with("SG"))
539}
540
541/// Extract a field value from an assembled segment using element/component indices.
542fn extract_value<'a>(segment: &'a AssembledSegment, rule: &AhbFieldRule) -> Option<&'a str> {
543    let elem_idx = rule.element_index.unwrap_or(0);
544    let comp_idx = rule.component_index.unwrap_or(0);
545
546    let element = segment.elements.get(elem_idx)?;
547    let component = element.get(comp_idx)?;
548
549    if component.is_empty() {
550        None
551    } else {
552        Some(component.as_str())
553    }
554}
555
556/// Resolve assembled groups against grouped AHB rules, recursively.
557///
558/// `depth` is the number of group prefixes to strip from each rule's
559/// `segment_path` before classifying it as direct or child.
560fn resolve_groups<'a>(
561    assembled_groups: &'a [AssembledGroup],
562    group_rules: &HashMap<String, Vec<&'a AhbFieldRule>>,
563    depth: usize,
564    narrowed: bool,
565) -> Vec<AhbGroupNode<'a>> {
566    let mut result = Vec::new();
567
568    for assembled_group in assembled_groups {
569        let rules = group_rules.get(&assembled_group.group_id);
570
571        for (instance_index, instance) in assembled_group.repetitions.iter().enumerate() {
572            let mut node =
573                resolve_group_instance(&assembled_group.group_id, instance, rules, depth, narrowed);
574            node.instance_index = instance_index;
575            result.push(node);
576        }
577    }
578
579    result
580}
581
582/// Strip `n` leading group prefixes from a segment path.
583///
584/// `strip_n_groups("SG4/SG5/LOC/3225", 2)` -> `"LOC/3225"`
585fn strip_n_groups(path: &str, n: usize) -> &str {
586    let mut rest = path;
587    for _ in 0..n {
588        match rest.find('/') {
589            Some(idx) => rest = &rest[idx + 1..],
590            None => return rest,
591        }
592    }
593    rest
594}
595
596/// Resolve a single group instance.
597///
598/// `depth` is how many group prefixes have been consumed so far (0 for top-level groups).
599///
600/// Rules are filtered to this instance by `mig_number`: a rule only applies if
601/// its `mig_number` matches a segment in this instance (or a segment in a child
602/// group instance). This prevents rules for SG8/SEQ+Z79 from generating false
603/// missing-field errors against an SG8/SEQ+Z01 rep.
604fn resolve_group_instance<'a>(
605    group_id: &'a str,
606    instance: &'a AssembledGroupInstance,
607    rules: Option<&Vec<&'a AhbFieldRule>>,
608    depth: usize,
609    narrowed: bool,
610) -> AhbGroupNode<'a> {
611    // We need to strip (depth + 1) group prefixes to get below this group level.
612    let strip_count = depth + 1;
613
614    // Use the variant's full set of mig_numbers (from MIG definition) to
615    // determine which rules belong to this instance. This includes numbers
616    // for segments that may be absent — so missing-field detection still works.
617    // Falls back to collecting from present segments if variant_mig_numbers is empty.
618    let variant_numbers: std::collections::HashSet<&str> =
619        if !instance.variant_mig_numbers.is_empty() {
620            instance
621                .variant_mig_numbers
622                .iter()
623                .map(|s| s.as_str())
624                .collect()
625        } else {
626            collect_instance_mig_numbers(instance)
627        };
628
629    let mut direct_rules: Vec<&'a AhbFieldRule> = Vec::new();
630    let mut child_group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
631    let mut ahb_status: Option<&'a str> = None;
632
633    // A group merged from several variants (QUOTES SG28: CCI+++E13 00029,
634    // CCI+++Z75 00042, …) carries one variant's MIG numbers and entry for
635    // every instance. The instance's own variant is the one whose entry code
636    // its entry segment carries; only that variant's rules apply.
637    let merged = rules.and_then(|rules| {
638        let own: Vec<&'a AhbFieldRule> = rules
639            .iter()
640            .copied()
641            .filter(|r| extract_child_group(strip_n_groups(&r.segment_path, strip_count)).is_none())
642            .collect();
643        merged_variant(&own, instance)
644    });
645
646    // The span of this instance's MIG numbers. A child group's rules belong
647    // to it when they lie inside: variants occupy contiguous MIG ranges, and
648    // the instance's own numbers name only one variant of a merged child
649    // (QUOTES SG28 keeps CCI+++Z75's), not all of them.
650    let span = variant_numbers
651        .iter()
652        .filter_map(|n| n.parse::<u32>().ok())
653        .fold(None, |acc: Option<(u32, u32)>, n| {
654            Some(acc.map_or((n, n), |(lo, hi)| (lo.min(n), hi.max(n))))
655        });
656
657    // The rules a merged parent (`narrowed`) handed down are its own
658    // variant's already (QUOTES 15005's SG31 under SG27 LIN+Z19,
659    // 00084–00086). When none of them is among the numbers the assembler
660    // recorded for this instance — another variant's (00096–00098, under
661    // LIN+Z56) — they all apply; filtered by those numbers, the SG31 of every
662    // position but the last kept no rule and was never checked.
663    //
664    // Only the group's own rules count here, not its child groups': the
665    // recorded numbers include the children's (PRICAT 27003's SG36 records
666    // its SG40 PRI 00019), so a child rule of this variant would look like a
667    // match and drop the group's own rules (LIN 00016 under PGI+9) — the SG36
668    // then kept no field, was never checked, and read as absent.
669    let handed_down = narrowed
670        && rules.is_some_and(|rules| {
671            let own: Vec<&&'a AhbFieldRule> = rules
672                .iter()
673                .filter(|r| {
674                    extract_child_group(strip_n_groups(&r.segment_path, strip_count)).is_none()
675                })
676                .collect();
677            !own.is_empty()
678                && !own.iter().any(|r| {
679                    r.mig_number
680                        .as_deref()
681                        .is_some_and(|n| variant_numbers.contains(n))
682                })
683        });
684    if let Some(rules) = rules {
685        for rule in rules {
686            // Strip all parent group prefixes plus this group to get the relative path.
687            let stripped = strip_n_groups(&rule.segment_path, strip_count);
688            let child = extract_child_group(stripped);
689
690            // Skip rules whose mig_number doesn't belong to this variant.
691            // Rules without mig_number pass through (tag-based fallback).
692            if let Some(ref rule_mig) = rule.mig_number {
693                let number = rule_mig.parse::<u32>().ok();
694                let belongs = match (&merged, child) {
695                    (Some((_, range)), _) => number.is_some_and(|m| range.contains(&m)),
696                    (None, _) if handed_down => true,
697                    (None, Some(_)) => match (number, span) {
698                        (Some(m), Some((lo, hi))) => (lo..=hi).contains(&m),
699                        _ => variant_numbers.contains(rule_mig.as_str()),
700                    },
701                    (None, None) => variant_numbers.contains(rule_mig.as_str()),
702                };
703                if !belongs {
704                    continue;
705                }
706            }
707
708            if let Some(child_group_id) = child {
709                child_group_rules
710                    .entry(child_group_id.to_owned())
711                    .or_default()
712                    .push(rule);
713            } else {
714                direct_rules.push(rule);
715            }
716        }
717    }
718    // The group's status is its variant's: read from the rule of the instance's
719    // entry segment, else from any rule of this group proper with a MIG number
720    // of the instance — never a child group's rule (it carries the child's
721    // status) or a rule without a MIG number (it may be another variant's).
722    let own_status = |rule: &&'a AhbFieldRule| rule.parent_group_ahb_status.as_deref();
723    let entry = merged
724        .as_ref()
725        .map(|(entry, _)| *entry)
726        .or(instance.entry_mig_number.as_deref());
727    if let Some(status) = direct_rules
728        .iter()
729        .filter(|r| entry.is_some() && r.mig_number.as_deref() == entry)
730        .find_map(own_status)
731        .or_else(|| {
732            direct_rules
733                .iter()
734                .filter(|r| r.mig_number.is_some())
735                .find_map(own_status)
736        })
737    {
738        ahb_status = Some(status);
739    }
740
741    // Resolve direct fields against instance segments.
742    // The segments of a merged group's instance carry the first variant's MIG
743    // numbers, not their own: per tag, the n-th segment takes the variant's
744    // n-th slot (the assembler fills slots in MIG order too).
745    let numbers: Option<Vec<Option<String>>> = merged.as_ref().map(|_| {
746        let mut used: Vec<&str> = Vec::new();
747        instance
748            .segments
749            .iter()
750            .map(|seg| {
751                let of_tag = |r: &&&'a AhbFieldRule| {
752                    extract_segment_tag(&r.segment_path) == Some(seg.tag.as_str())
753                };
754                let mut slots: Vec<&str> = direct_rules
755                    .iter()
756                    .filter(of_tag)
757                    .filter_map(|r| r.mig_number.as_deref())
758                    .filter(|n| !used.contains(n))
759                    .collect();
760                slots.sort_by_key(|n| n.parse::<u32>().unwrap_or(u32::MAX));
761                slots.dedup();
762                // The slot whose codes the segment carries; else the first
763                // slot that lists no codes; else the next one in MIG order.
764                let coded = |slot: &str| {
765                    direct_rules
766                        .iter()
767                        .filter(of_tag)
768                        .filter(|r| r.mig_number.as_deref() == Some(slot) && !r.codes.is_empty())
769                        .collect::<Vec<_>>()
770                };
771                let carries = |slot: &&str| {
772                    coded(slot).iter().any(|r| {
773                        seg.elements
774                            .get(r.element_index.unwrap_or(0))
775                            .and_then(|e| e.get(r.component_index.unwrap_or(0)))
776                            .is_some_and(|v| r.codes.iter().any(|c| &c.value == v))
777                    })
778                };
779                let slot = slots
780                    .iter()
781                    .find(|s| carries(s))
782                    .or_else(|| slots.iter().find(|s| coded(s).is_empty()))
783                    .or_else(|| slots.first())
784                    .copied();
785                if let Some(slot) = slot {
786                    used.push(slot);
787                }
788                slot.map(str::to_string)
789            })
790            .collect()
791    });
792    // Handed-down rules carry this variant's MIG numbers and the segments
793    // another variant's: match the segments by tag and qualifier only.
794    let numbers = if handed_down && numbers.is_none() {
795        Some(vec![None; instance.segments.len()])
796    } else {
797        numbers
798    };
799    let fields = resolve_fields(&direct_rules, &instance.segments, numbers.as_deref());
800
801    // Recurse into child groups one level deeper.
802    let children = resolve_groups(
803        &instance.child_groups,
804        &child_group_rules,
805        strip_count,
806        merged.is_some(),
807    );
808
809    let mut absent_children = Vec::new();
810    for (child_id, rules) in &child_group_rules {
811        absent_children.extend(absent_variant_rules(
812            child_id,
813            rules,
814            &children,
815            strip_count + 1,
816        ));
817    }
818    absent_children.sort_by_key(|r| r.mig_number.as_deref().and_then(|n| n.parse::<u32>().ok()));
819    let mut variant_rules = direct_rules;
820    variant_rules.extend(child_group_rules.into_values().flatten());
821
822    AhbGroupNode {
823        group_id,
824        instance_index: 0,
825        ahb_status,
826        fields,
827        children,
828        variant_rules,
829        absent_children,
830    }
831}
832
833/// The codes an entry segment lists, by (element, component).
834type Profile<'a> = std::collections::BTreeMap<(usize, usize), Vec<&'a str>>;
835
836/// The rules of `child_id`'s variants that no instance among `children`
837/// claims: for each, the rules of its own segments. `rules` are every rule
838/// below this instance under `child_id`; `strip` the group prefixes to strip
839/// to reach the child's own segments.
840///
841/// Variants are told apart by the codes of their entry segment: the entry tag
842/// is that of the child's first rule in MIG order, and each variant starts at
843/// a segment of that tag. Every variant must code some position of it, and
844/// every two must differ at one position — both code it, with disjoint codes
845/// (`SG8 SEQ+Z01`, `SEQ+Z02`), or only one lists it at all (SG10 `CCI+Z39`
846/// beside `CCI+++ZE4`, which leaves 7059 empty, #351). A child whose variants
847/// cannot be told apart this way yields nothing.
848fn absent_variant_rules<'a>(
849    child_id: &str,
850    rules: &[&'a AhbFieldRule],
851    children: &[AhbGroupNode<'a>],
852    strip: usize,
853) -> Vec<&'a AhbFieldRule> {
854    let number = |r: &AhbFieldRule| r.mig_number.as_deref()?.parse::<u32>().ok();
855    let own: Vec<&'a AhbFieldRule> = rules
856        .iter()
857        .copied()
858        .filter(|r| extract_child_group(strip_n_groups(&r.segment_path, strip)).is_none())
859        .filter(|r| number(r).is_some())
860        .collect();
861    let Some(entry_tag) = own
862        .iter()
863        .min_by_key(|r| number(r))
864        .and_then(|r| extract_segment_tag(&r.segment_path))
865    else {
866        return Vec::new();
867    };
868    let is_entry_tag = |r: &AhbFieldRule| extract_segment_tag(&r.segment_path) == Some(entry_tag);
869    let starts: std::collections::BTreeSet<u32> = own
870        .iter()
871        .filter(|r| is_entry_tag(r))
872        .filter_map(|r| number(r))
873        .collect();
874    // Per variant: its entry rule, and the codes its entry segment lists at
875    // each (element, component) — none for a data position.
876    let mut entries: Vec<(u32, &'a AhbFieldRule, Profile<'a>)> = Vec::new();
877    for &start in &starts {
878        let mut profile = Profile::new();
879        let mut entry = None;
880        for r in own
881            .iter()
882            .copied()
883            .filter(|r| is_entry_tag(r) && number(r) == Some(start))
884        {
885            entry.get_or_insert(r);
886            profile
887                .entry((r.element_index.unwrap_or(0), r.component_index.unwrap_or(0)))
888                .or_default()
889                .extend(r.codes.iter().map(|c| c.value.as_str()));
890        }
891        if let Some(entry) = entry {
892            entries.push((start, entry, profile));
893        }
894    }
895    let coded = |p: &Profile| p.values().any(|codes| !codes.is_empty());
896    // `a` codes a position `b` either leaves out or codes otherwise.
897    let apart = |a: &Profile, b: &Profile| {
898        a.iter().any(|(pos, codes)| {
899            !codes.is_empty()
900                && b.get(pos).map_or(true, |other| {
901                    !other.is_empty() && !codes.iter().any(|c| other.contains(c))
902                })
903        })
904    };
905    let distinct = entries.iter().all(|(_, _, p)| coded(p))
906        && entries.iter().enumerate().all(|(i, (_, _, a))| {
907            entries[i + 1..]
908                .iter()
909                .all(|(_, _, b)| apart(a, b) || apart(b, a))
910        });
911    if !distinct {
912        return Vec::new();
913    }
914    let claimed: std::collections::HashSet<&str> = children
915        .iter()
916        .filter(|c| c.group_id == child_id)
917        .flat_map(|c| c.fields.iter().filter_map(|n| n.rule.mig_number.as_deref()))
918        .collect();
919    let mut out = Vec::new();
920    for (i, (start, entry, _)) in entries.iter().enumerate() {
921        if claimed.contains(entry.mig_number.as_deref().unwrap_or_default()) {
922            continue;
923        }
924        let end = entries.get(i + 1).map_or(u32::MAX, |(n, _, _)| *n);
925        out.extend(
926            own.iter()
927                .copied()
928                .filter(|r| number(r).is_some_and(|n| (*start..end).contains(&n))),
929        );
930    }
931    out
932}
933
934/// Collect all mig_numbers present in a group instance, including child groups recursively.
935fn collect_instance_mig_numbers(
936    instance: &AssembledGroupInstance,
937) -> std::collections::HashSet<&str> {
938    let mut numbers = std::collections::HashSet::new();
939    for seg in &instance.segments {
940        if let Some(ref num) = seg.mig_number {
941            numbers.insert(num.as_str());
942        }
943    }
944    for child_group in &instance.child_groups {
945        for child_instance in &child_group.repetitions {
946            numbers.extend(collect_instance_mig_numbers(child_instance));
947        }
948    }
949    numbers
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use crate::validator::validate::{AhbFieldRule, AhbWorkflow};
956    use mig_assembly::assembler::{
957        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
958    };
959    use std::collections::BTreeMap;
960
961    fn empty_workflow() -> AhbWorkflow {
962        AhbWorkflow {
963            pruefidentifikator: "11001".to_string(),
964            description: String::new(),
965            communication_direction: None,
966            fields: vec![],
967            ub_definitions: BTreeMap::new(),
968            package_conditions: Default::default(),
969        }
970    }
971
972    fn empty_tree() -> AssembledTree {
973        AssembledTree {
974            segments: vec![],
975            groups: vec![],
976            post_group_start: 0,
977            inter_group_segments: BTreeMap::new(),
978        }
979    }
980
981    fn make_segment(
982        tag: &str,
983        elements: Vec<Vec<&str>>,
984        mig_number: Option<&str>,
985    ) -> AssembledSegment {
986        AssembledSegment {
987            tag: tag.to_string(),
988            elements: elements
989                .into_iter()
990                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
991                .collect(),
992            mig_number: mig_number.map(|s| s.to_string()),
993            segment_number: None,
994        }
995    }
996
997    fn make_rule(
998        segment_path: &str,
999        name: &str,
1000        ahb_status: &str,
1001        mig_number: Option<&str>,
1002        element_index: Option<usize>,
1003        component_index: Option<usize>,
1004    ) -> AhbFieldRule {
1005        AhbFieldRule {
1006            segment_path: segment_path.to_string(),
1007            name: name.to_string(),
1008            ahb_status: ahb_status.to_string(),
1009            codes: vec![],
1010            parent_group_ahb_status: None,
1011            segment_ahb_status: None,
1012            element_index,
1013            component_index,
1014            mig_number: mig_number.map(|s| s.to_string()),
1015            format: None,
1016        }
1017    }
1018
1019    #[test]
1020    fn test_empty_workflow_empty_tree() {
1021        let workflow = empty_workflow();
1022        let tree = empty_tree();
1023        let result = build_validated_tree(&workflow, &tree);
1024
1025        assert_eq!(result.pruefidentifikator, "11001");
1026        assert!(result.root_fields.is_empty());
1027        assert!(result.groups.is_empty());
1028    }
1029
1030    #[test]
1031    fn test_root_field_matches_root_segment() {
1032        let mut workflow = empty_workflow();
1033        workflow.fields.push(make_rule(
1034            "BGM/C002/1001",
1035            "Nachrichtentyp",
1036            "X",
1037            Some("0001"),
1038            Some(0),
1039            Some(0),
1040        ));
1041
1042        let tree = AssembledTree {
1043            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
1044            groups: vec![],
1045            post_group_start: 1,
1046            inter_group_segments: BTreeMap::new(),
1047        };
1048
1049        let result = build_validated_tree(&workflow, &tree);
1050
1051        assert_eq!(result.root_fields.len(), 1);
1052        let node = &result.root_fields[0];
1053        assert_eq!(node.value, Some("E01"));
1054        assert!(node.segment_elements.is_some());
1055        assert_eq!(node.rule.name, "Nachrichtentyp");
1056    }
1057
1058    #[test]
1059    fn test_sg4_dtm_mig_number_matching() {
1060        // Two DTM segments with different mig_numbers.
1061        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
1062        let mut workflow = empty_workflow();
1063
1064        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
1065        workflow.fields.push(make_rule(
1066            "SG4/DTM/C507/2380",
1067            "Eingangsdatum",
1068            "X",
1069            Some("0082"),
1070            Some(1), // date value is element 1
1071            Some(0),
1072        ));
1073
1074        // Rule for DTM+137.
1075        workflow.fields.push(make_rule(
1076            "SG4/DTM/C507/2380",
1077            "Dokumentendatum",
1078            "X",
1079            Some("0083"),
1080            Some(1),
1081            Some(0),
1082        ));
1083
1084        let tree = AssembledTree {
1085            segments: vec![],
1086            groups: vec![AssembledGroup {
1087                group_id: "SG4".to_string(),
1088                repetitions: vec![AssembledGroupInstance {
1089                    segments: vec![
1090                        make_segment("DTM", vec![vec!["92"], vec!["20260101"]], Some("0082")),
1091                        make_segment("DTM", vec![vec!["137"], vec!["20260401"]], Some("0083")),
1092                    ],
1093                    child_groups: vec![],
1094                    entry_mig_number: None,
1095                    variant_mig_numbers: vec![],
1096                    skipped_segments: vec![],
1097                    skipped_positions: Vec::new(),
1098                }],
1099            }],
1100            post_group_start: 0,
1101            inter_group_segments: BTreeMap::new(),
1102        };
1103
1104        let result = build_validated_tree(&workflow, &tree);
1105
1106        assert_eq!(result.groups.len(), 1);
1107        let sg4 = &result.groups[0];
1108        assert_eq!(sg4.group_id, "SG4");
1109        assert_eq!(sg4.fields.len(), 2);
1110
1111        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
1112        let eingangsdatum = &sg4.fields[0];
1113        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
1114        assert_eq!(eingangsdatum.value, Some("20260101"));
1115
1116        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
1117        let dokumentendatum = &sg4.fields[1];
1118        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
1119        assert_eq!(dokumentendatum.value, Some("20260401"));
1120    }
1121
1122    #[test]
1123    fn test_rule_filtered_to_correct_variant() {
1124        // A rule with mig_number "0099" is filtered out from an instance
1125        // that doesn't contain any segment with that mig_number.
1126        // This prevents false missing-field errors for wrong SG8 variants.
1127        let mut workflow = empty_workflow();
1128        workflow.fields.push(make_rule(
1129            "SG4/RFF/C506/1154",
1130            "Referenz",
1131            "X",
1132            Some("0099"),
1133            Some(0),
1134            Some(1),
1135        ));
1136
1137        let tree = AssembledTree {
1138            segments: vec![],
1139            groups: vec![AssembledGroup {
1140                group_id: "SG4".to_string(),
1141                repetitions: vec![AssembledGroupInstance {
1142                    segments: vec![], // No segments — wrong variant
1143                    child_groups: vec![],
1144                    entry_mig_number: None,
1145                    variant_mig_numbers: vec![],
1146                    skipped_segments: vec![],
1147                    skipped_positions: Vec::new(),
1148                }],
1149            }],
1150            post_group_start: 0,
1151            inter_group_segments: BTreeMap::new(),
1152        };
1153
1154        let result = build_validated_tree(&workflow, &tree);
1155        assert_eq!(result.groups.len(), 1);
1156        // Rule is filtered out — its mig_number doesn't match this instance
1157        assert_eq!(result.groups[0].fields.len(), 0);
1158    }
1159
1160    #[test]
1161    fn test_missing_segment_within_correct_variant() {
1162        // A rule with mig_number "0099" IS included when the instance's
1163        // variant_mig_numbers lists it — even though the segment is absent.
1164        // This enables missing-field detection within the correct variant.
1165        let mut workflow = empty_workflow();
1166        // Entry segment rule (present)
1167        workflow.fields.push(make_rule(
1168            "SG4/SEQ/1229",
1169            "Qualifier",
1170            "X",
1171            Some("0098"),
1172            Some(0),
1173            Some(0),
1174        ));
1175        // Second segment rule (absent — should report missing)
1176        workflow.fields.push(make_rule(
1177            "SG4/RFF/C506/1154",
1178            "Referenz",
1179            "X",
1180            Some("0099"),
1181            Some(0),
1182            Some(1),
1183        ));
1184
1185        let tree = AssembledTree {
1186            segments: vec![],
1187            groups: vec![AssembledGroup {
1188                group_id: "SG4".to_string(),
1189                repetitions: vec![AssembledGroupInstance {
1190                    segments: vec![
1191                        // Only the entry segment — RFF is missing
1192                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
1193                    ],
1194                    child_groups: vec![],
1195                    entry_mig_number: Some("0098".to_string()),
1196                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
1197                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
1198                    skipped_segments: vec![],
1199                    skipped_positions: Vec::new(),
1200                }],
1201            }],
1202            post_group_start: 0,
1203            inter_group_segments: BTreeMap::new(),
1204        };
1205
1206        let result = build_validated_tree(&workflow, &tree);
1207        assert_eq!(result.groups.len(), 1);
1208        // Both rules match because variant_mig_numbers includes both
1209        assert_eq!(result.groups[0].fields.len(), 2);
1210        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
1211        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
1212        // RFF is missing — value is None (condition eval will report it)
1213        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
1214        assert_eq!(result.groups[0].fields[1].value, None);
1215    }
1216
1217    #[test]
1218    fn test_missing_group_variant_populates_unmatched_rules() {
1219        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
1220        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
1221        // so validate_tree can report them as missing using flat logic.
1222        let mut workflow = empty_workflow();
1223
1224        // NAD+MS qualifier rule
1225        workflow.fields.push(make_rule(
1226            "SG2/NAD/3035",
1227            "MP-ID Absender Qualifier",
1228            "X",
1229            Some("0010"),
1230            Some(0),
1231            Some(0),
1232        ));
1233        // NAD+MS ID rule
1234        workflow.fields.push(make_rule(
1235            "SG2/NAD/C082/3039",
1236            "MP-ID Absender",
1237            "X",
1238            Some("0010"),
1239            Some(1),
1240            Some(0),
1241        ));
1242        // NAD+MR qualifier rule
1243        workflow.fields.push(make_rule(
1244            "SG2/NAD/3035",
1245            "MP-ID Empfänger Qualifier",
1246            "X",
1247            Some("0011"),
1248            Some(0),
1249            Some(0),
1250        ));
1251        // NAD+MR ID rule
1252        workflow.fields.push(make_rule(
1253            "SG2/NAD/C082/3039",
1254            "MP-ID Empfänger",
1255            "X",
1256            Some("0011"),
1257            Some(1),
1258            Some(0),
1259        ));
1260
1261        // Only NAD+MR present in assembled tree.
1262        let tree = AssembledTree {
1263            segments: vec![],
1264            groups: vec![AssembledGroup {
1265                group_id: "SG2".to_string(),
1266                repetitions: vec![AssembledGroupInstance {
1267                    segments: vec![make_segment(
1268                        "NAD",
1269                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
1270                        Some("0011"),
1271                    )],
1272                    child_groups: vec![],
1273                    entry_mig_number: Some("0011".to_string()),
1274                    variant_mig_numbers: vec!["0011".to_string()],
1275                    skipped_segments: vec![],
1276                    skipped_positions: Vec::new(),
1277                }],
1278            }],
1279            post_group_start: 0,
1280            inter_group_segments: BTreeMap::new(),
1281        };
1282
1283        let result = build_validated_tree(&workflow, &tree);
1284
1285        // Tree should have 1 SG2 group node (NAD+MR).
1286        assert_eq!(result.groups.len(), 1);
1287        assert_eq!(result.groups[0].fields.len(), 2);
1288        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
1289
1290        // NAD+MS rules should be in unmatched_rules.
1291        assert_eq!(
1292            result.unmatched_rules.len(),
1293            2,
1294            "Expected 2 unmatched rules (NAD+MS), got {}",
1295            result.unmatched_rules.len()
1296        );
1297        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1298        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
1299    }
1300
1301    #[test]
1302    fn test_entirely_absent_group_populates_unmatched_rules() {
1303        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
1304        let mut workflow = empty_workflow();
1305        workflow.fields.push(make_rule(
1306            "SG2/NAD/3035",
1307            "MP-ID Absender Qualifier",
1308            "X",
1309            Some("0010"),
1310            Some(0),
1311            Some(0),
1312        ));
1313
1314        let tree = empty_tree(); // No groups at all.
1315
1316        let result = build_validated_tree(&workflow, &tree);
1317
1318        // No tree nodes.
1319        assert!(result.groups.is_empty());
1320
1321        // The rule should be in unmatched_rules.
1322        assert_eq!(
1323            result.unmatched_rules.len(),
1324            1,
1325            "Expected 1 unmatched rule, got {}",
1326            result.unmatched_rules.len()
1327        );
1328        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1329    }
1330
1331    #[test]
1332    fn test_fallback_to_tag_when_no_mig_number() {
1333        let mut workflow = empty_workflow();
1334        workflow.fields.push(make_rule(
1335            "BGM/C002/1001",
1336            "Nachrichtentyp",
1337            "X",
1338            None, // No mig_number — should fall back to tag.
1339            Some(0),
1340            Some(0),
1341        ));
1342
1343        let tree = AssembledTree {
1344            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
1345            groups: vec![],
1346            post_group_start: 1,
1347            inter_group_segments: BTreeMap::new(),
1348        };
1349
1350        let result = build_validated_tree(&workflow, &tree);
1351        assert_eq!(result.root_fields.len(), 1);
1352        assert_eq!(result.root_fields[0].value, Some("E01"));
1353    }
1354
1355    #[test]
1356    fn test_nested_child_groups() {
1357        let mut workflow = empty_workflow();
1358        // A rule in SG4/SG5.
1359        workflow.fields.push(make_rule(
1360            "SG4/SG5/LOC/C517/3225",
1361            "Marktlokations-ID",
1362            "X",
1363            Some("0050"),
1364            Some(0),
1365            Some(0),
1366        ));
1367
1368        let tree = AssembledTree {
1369            segments: vec![],
1370            groups: vec![AssembledGroup {
1371                group_id: "SG4".to_string(),
1372                repetitions: vec![AssembledGroupInstance {
1373                    segments: vec![],
1374                    entry_mig_number: None,
1375                    child_groups: vec![AssembledGroup {
1376                        group_id: "SG5".to_string(),
1377                        repetitions: vec![AssembledGroupInstance {
1378                            segments: vec![make_segment(
1379                                "LOC",
1380                                vec![vec!["DE00012345678"]],
1381                                Some("0050"),
1382                            )],
1383                            child_groups: vec![],
1384                            entry_mig_number: None,
1385                            variant_mig_numbers: vec![],
1386                            skipped_segments: vec![],
1387                            skipped_positions: Vec::new(),
1388                        }],
1389                    }],
1390                    variant_mig_numbers: vec![],
1391                    skipped_segments: vec![],
1392                    skipped_positions: Vec::new(),
1393                }],
1394            }],
1395            post_group_start: 0,
1396            inter_group_segments: BTreeMap::new(),
1397        };
1398
1399        let result = build_validated_tree(&workflow, &tree);
1400        assert_eq!(result.groups.len(), 1);
1401        let sg4 = &result.groups[0];
1402        assert_eq!(sg4.children.len(), 1);
1403
1404        let sg5 = &sg4.children[0];
1405        assert_eq!(sg5.group_id, "SG5");
1406        assert_eq!(sg5.fields.len(), 1);
1407        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
1408        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
1409    }
1410
1411    #[test]
1412    fn test_qualifier_aware_segment_matching_same_tag_variants() {
1413        // MSCONS SG10 has four STS variants (9015=Z33, Z32, Z34, Z40) with
1414        // mig_numbers 00035..00038. The assembler matches same-tag MIG slots
1415        // positionally, so when input contains STS+Z32++Z92 and STS+Z40++Z75,
1416        // the segments end up tagged with mig=00035 and mig=00036 (the first
1417        // two MIG slots) instead of mig=00036 and mig=00038.
1418        //
1419        // `find_segment` must therefore prefer a segment whose qualifier value
1420        // matches the rule's single-code constraint over the assembler's
1421        // positional mig_number. Otherwise:
1422        //   * rule for Z33 (mig 00035) attaches to the Z32 segment and looks
1423        //     "present" with the wrong value, suppressing the genuine absence;
1424        //   * rule for Z40 (mig 00038) finds no segment and reports the Z40
1425        //     STS as "missing" even though it is present in the input.
1426        let mut workflow = empty_workflow();
1427
1428        // Z33 variant — not present in input, must resolve to None.
1429        let mut z33 = make_rule(
1430            "SG10/STS/C601/9015",
1431            "Statuskategorie Z33",
1432            "X",
1433            Some("00035"),
1434            Some(0),
1435            Some(0),
1436        );
1437        z33.codes = vec![super::super::validate::AhbCodeRule {
1438            value: "Z33".into(),
1439            description: String::new(),
1440            ahb_status: "X".into(),
1441        }];
1442        workflow.fields.push(z33);
1443
1444        // Z32 variant — present in input.
1445        let mut z32 = make_rule(
1446            "SG10/STS/C601/9015",
1447            "Statuskategorie Z32",
1448            "X",
1449            Some("00036"),
1450            Some(0),
1451            Some(0),
1452        );
1453        z32.codes = vec![super::super::validate::AhbCodeRule {
1454            value: "Z32".into(),
1455            description: String::new(),
1456            ahb_status: "X".into(),
1457        }];
1458        workflow.fields.push(z32);
1459
1460        // Z40 variant — present in input.
1461        let mut z40 = make_rule(
1462            "SG10/STS/C601/9015",
1463            "Statuskategorie Z40",
1464            "X",
1465            Some("00038"),
1466            Some(0),
1467            Some(0),
1468        );
1469        z40.codes = vec![super::super::validate::AhbCodeRule {
1470            value: "Z40".into(),
1471            description: String::new(),
1472            ahb_status: "X".into(),
1473        }];
1474        workflow.fields.push(z40);
1475
1476        // Assembler assigned mig=00035 to the Z32 segment and mig=00036 to the Z40
1477        // segment (positional), mimicking the real MSCONS 13025 pipeline output.
1478        let tree = AssembledTree {
1479            segments: vec![],
1480            groups: vec![AssembledGroup {
1481                group_id: "SG10".to_string(),
1482                repetitions: vec![AssembledGroupInstance {
1483                    segments: vec![
1484                        make_segment("STS", vec![vec!["Z32"], vec![], vec!["Z92"]], Some("00035")),
1485                        make_segment("STS", vec![vec!["Z40"], vec![], vec!["Z75"]], Some("00036")),
1486                    ],
1487                    child_groups: vec![],
1488                    entry_mig_number: None,
1489                    variant_mig_numbers: vec![
1490                        "00035".into(),
1491                        "00036".into(),
1492                        "00037".into(),
1493                        "00038".into(),
1494                    ],
1495                    skipped_segments: vec![],
1496                    skipped_positions: Vec::new(),
1497                }],
1498            }],
1499            post_group_start: 0,
1500            inter_group_segments: BTreeMap::new(),
1501        };
1502
1503        let result = build_validated_tree(&workflow, &tree);
1504        assert_eq!(result.groups.len(), 1);
1505        let sg10 = &result.groups[0];
1506        assert_eq!(
1507            sg10.fields.len(),
1508            3,
1509            "all three rules should pass the variant filter"
1510        );
1511
1512        let by_name = |name: &str| sg10.fields.iter().find(|f| f.rule.name == name).unwrap();
1513
1514        // Z33 is genuinely absent — no STS in the input has 9015=Z33.
1515        assert_eq!(
1516            by_name("Statuskategorie Z33").value,
1517            None,
1518            "Z33 has no matching STS in the input; must not silently pick up Z32's segment"
1519        );
1520
1521        // Z32 must resolve to the STS segment whose 9015 is Z32,
1522        // regardless of the assembler's mis-assigned mig_number.
1523        assert_eq!(
1524            by_name("Statuskategorie Z32").value,
1525            Some("Z32"),
1526            "rule for Z32 must attach to the STS segment with qualifier Z32"
1527        );
1528
1529        // Z40 must resolve to the STS segment whose 9015 is Z40.
1530        assert_eq!(
1531            by_name("Statuskategorie Z40").value,
1532            Some("Z40"),
1533            "rule for Z40 must attach to the STS segment with qualifier Z40"
1534        );
1535    }
1536}