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