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    let status_of = |read: fn(&'a AhbFieldRule) -> Option<&'a str>| {
732        direct_rules
733            .iter()
734            .filter(|r| entry.is_some() && r.mig_number.as_deref() == entry)
735            .find_map(|r| read(r))
736            .or_else(|| {
737                direct_rules
738                    .iter()
739                    .filter(|r| r.mig_number.is_some())
740                    .find_map(|r| read(r))
741            })
742    };
743    if let Some(status) = status_of(|r| r.parent_group_ahb_status.as_deref()) {
744        ahb_status = Some(status);
745    }
746    let own_status = status_of(|r| r.group_ahb_status.as_deref());
747
748    // Resolve direct fields against instance segments.
749    // The segments of a merged group's instance carry the first variant's MIG
750    // numbers, not their own: per tag, the n-th segment takes the variant's
751    // n-th slot (the assembler fills slots in MIG order too).
752    let numbers: Option<Vec<Option<String>>> = merged.as_ref().map(|_| {
753        let mut used: Vec<&str> = Vec::new();
754        instance
755            .segments
756            .iter()
757            .map(|seg| {
758                let of_tag = |r: &&&'a AhbFieldRule| {
759                    extract_segment_tag(&r.segment_path) == Some(seg.tag.as_str())
760                };
761                let mut slots: Vec<&str> = direct_rules
762                    .iter()
763                    .filter(of_tag)
764                    .filter_map(|r| r.mig_number.as_deref())
765                    .filter(|n| !used.contains(n))
766                    .collect();
767                slots.sort_by_key(|n| n.parse::<u32>().unwrap_or(u32::MAX));
768                slots.dedup();
769                // The slot whose codes the segment carries; else the first
770                // slot that lists no codes; else the next one in MIG order.
771                let coded = |slot: &str| {
772                    direct_rules
773                        .iter()
774                        .filter(of_tag)
775                        .filter(|r| r.mig_number.as_deref() == Some(slot) && !r.codes.is_empty())
776                        .collect::<Vec<_>>()
777                };
778                let carries = |slot: &&str| {
779                    coded(slot).iter().any(|r| {
780                        seg.elements
781                            .get(r.element_index.unwrap_or(0))
782                            .and_then(|e| e.get(r.component_index.unwrap_or(0)))
783                            .is_some_and(|v| r.codes.iter().any(|c| &c.value == v))
784                    })
785                };
786                let slot = slots
787                    .iter()
788                    .find(|s| carries(s))
789                    .or_else(|| slots.iter().find(|s| coded(s).is_empty()))
790                    .or_else(|| slots.first())
791                    .copied();
792                if let Some(slot) = slot {
793                    used.push(slot);
794                }
795                slot.map(str::to_string)
796            })
797            .collect()
798    });
799    // Handed-down rules carry this variant's MIG numbers and the segments
800    // another variant's: match the segments by tag and qualifier only.
801    let numbers = if handed_down && numbers.is_none() {
802        Some(vec![None; instance.segments.len()])
803    } else {
804        numbers
805    };
806    let fields = resolve_fields(&direct_rules, &instance.segments, numbers.as_deref());
807
808    // Recurse into child groups one level deeper.
809    let children = resolve_groups(
810        &instance.child_groups,
811        &child_group_rules,
812        strip_count,
813        merged.is_some(),
814    );
815
816    let mut absent_children = Vec::new();
817    for (child_id, rules) in &child_group_rules {
818        absent_children.extend(absent_variant_rules(
819            child_id,
820            rules,
821            &children,
822            strip_count + 1,
823        ));
824    }
825    absent_children.sort_by_key(|r| r.mig_number.as_deref().and_then(|n| n.parse::<u32>().ok()));
826    let mut variant_rules = direct_rules;
827    variant_rules.extend(child_group_rules.into_values().flatten());
828
829    AhbGroupNode {
830        group_id,
831        instance_index: 0,
832        ahb_status,
833        own_status,
834        fields,
835        children,
836        variant_rules,
837        absent_children,
838    }
839}
840
841/// The codes an entry segment lists, by (element, component).
842type Profile<'a> = std::collections::BTreeMap<(usize, usize), Vec<&'a str>>;
843
844/// The rules of `child_id`'s variants that no instance among `children`
845/// claims: for each, the rules of its own segments. `rules` are every rule
846/// below this instance under `child_id`; `strip` the group prefixes to strip
847/// to reach the child's own segments.
848///
849/// Variants are told apart by the codes of their entry segment: the entry tag
850/// is that of the child's first rule in MIG order, and each variant starts at
851/// a segment of that tag. Every variant must code some position of it, and
852/// every two must differ at one position — both code it, with disjoint codes
853/// (`SG8 SEQ+Z01`, `SEQ+Z02`), or only one lists it at all (SG10 `CCI+Z39`
854/// beside `CCI+++ZE4`, which leaves 7059 empty, #351). A child whose variants
855/// cannot be told apart this way yields nothing.
856fn absent_variant_rules<'a>(
857    child_id: &str,
858    rules: &[&'a AhbFieldRule],
859    children: &[AhbGroupNode<'a>],
860    strip: usize,
861) -> Vec<&'a AhbFieldRule> {
862    let number = |r: &AhbFieldRule| r.mig_number.as_deref()?.parse::<u32>().ok();
863    let own: Vec<&'a AhbFieldRule> = rules
864        .iter()
865        .copied()
866        .filter(|r| extract_child_group(strip_n_groups(&r.segment_path, strip)).is_none())
867        .filter(|r| number(r).is_some())
868        .collect();
869    let Some(entry_tag) = own
870        .iter()
871        .min_by_key(|r| number(r))
872        .and_then(|r| extract_segment_tag(&r.segment_path))
873    else {
874        return Vec::new();
875    };
876    let is_entry_tag = |r: &AhbFieldRule| extract_segment_tag(&r.segment_path) == Some(entry_tag);
877    let starts: std::collections::BTreeSet<u32> = own
878        .iter()
879        .filter(|r| is_entry_tag(r))
880        .filter_map(|r| number(r))
881        .collect();
882    // Per variant: its entry rule, and the codes its entry segment lists at
883    // each (element, component) — none for a data position.
884    let mut entries: Vec<(u32, &'a AhbFieldRule, Profile<'a>)> = Vec::new();
885    for &start in &starts {
886        let mut profile = Profile::new();
887        let mut entry = None;
888        for r in own
889            .iter()
890            .copied()
891            .filter(|r| is_entry_tag(r) && number(r) == Some(start))
892        {
893            entry.get_or_insert(r);
894            profile
895                .entry((r.element_index.unwrap_or(0), r.component_index.unwrap_or(0)))
896                .or_default()
897                .extend(r.codes.iter().map(|c| c.value.as_str()));
898        }
899        if let Some(entry) = entry {
900            entries.push((start, entry, profile));
901        }
902    }
903    let coded = |p: &Profile| p.values().any(|codes| !codes.is_empty());
904    // `a` codes a position `b` either leaves out or codes otherwise.
905    let apart = |a: &Profile, b: &Profile| {
906        a.iter().any(|(pos, codes)| {
907            !codes.is_empty()
908                && b.get(pos).map_or(true, |other| {
909                    !other.is_empty() && !codes.iter().any(|c| other.contains(c))
910                })
911        })
912    };
913    let distinct = entries.iter().all(|(_, _, p)| coded(p))
914        && entries.iter().enumerate().all(|(i, (_, _, a))| {
915            entries[i + 1..]
916                .iter()
917                .all(|(_, _, b)| apart(a, b) || apart(b, a))
918        });
919    if !distinct {
920        return Vec::new();
921    }
922    // A group of one variant is that variant wherever an instance of it is
923    // sent, whatever MIG numbers its segments were matched with: IFTSTA
924    // 21037's SG18 CTA+IC under NAD+PK, whose rules come handed down from
925    // three SG15 variants, resolves no field to tell it by.
926    if entries.len() == 1 && children.iter().any(|c| c.group_id == child_id) {
927        return Vec::new();
928    }
929    let claimed: std::collections::HashSet<&str> = children
930        .iter()
931        .filter(|c| c.group_id == child_id)
932        .flat_map(|c| c.fields.iter().filter_map(|n| n.rule.mig_number.as_deref()))
933        .collect();
934    let mut out = Vec::new();
935    for (i, (start, entry, _)) in entries.iter().enumerate() {
936        if claimed.contains(entry.mig_number.as_deref().unwrap_or_default()) {
937            continue;
938        }
939        let end = entries.get(i + 1).map_or(u32::MAX, |(n, _, _)| *n);
940        out.extend(
941            own.iter()
942                .copied()
943                .filter(|r| number(r).is_some_and(|n| (*start..end).contains(&n))),
944        );
945    }
946    out
947}
948
949/// Collect all mig_numbers present in a group instance, including child groups recursively.
950fn collect_instance_mig_numbers(
951    instance: &AssembledGroupInstance,
952) -> std::collections::HashSet<&str> {
953    let mut numbers = std::collections::HashSet::new();
954    for seg in &instance.segments {
955        if let Some(ref num) = seg.mig_number {
956            numbers.insert(num.as_str());
957        }
958    }
959    for child_group in &instance.child_groups {
960        for child_instance in &child_group.repetitions {
961            numbers.extend(collect_instance_mig_numbers(child_instance));
962        }
963    }
964    numbers
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::validator::validate::{AhbFieldRule, AhbWorkflow};
971    use mig_assembly::assembler::{
972        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
973    };
974    use std::collections::BTreeMap;
975
976    fn empty_workflow() -> AhbWorkflow {
977        AhbWorkflow {
978            pruefidentifikator: "11001".to_string(),
979            description: String::new(),
980            communication_direction: None,
981            fields: vec![],
982            ub_definitions: BTreeMap::new(),
983            package_conditions: Default::default(),
984        }
985    }
986
987    fn empty_tree() -> AssembledTree {
988        AssembledTree {
989            segments: vec![],
990            groups: vec![],
991            post_group_start: 0,
992            inter_group_segments: BTreeMap::new(),
993        }
994    }
995
996    fn make_segment(
997        tag: &str,
998        elements: Vec<Vec<&str>>,
999        mig_number: Option<&str>,
1000    ) -> AssembledSegment {
1001        AssembledSegment {
1002            tag: tag.to_string(),
1003            elements: elements
1004                .into_iter()
1005                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
1006                .collect(),
1007            mig_number: mig_number.map(|s| s.to_string()),
1008            segment_number: None,
1009        }
1010    }
1011
1012    fn make_rule(
1013        segment_path: &str,
1014        name: &str,
1015        ahb_status: &str,
1016        mig_number: Option<&str>,
1017        element_index: Option<usize>,
1018        component_index: Option<usize>,
1019    ) -> AhbFieldRule {
1020        AhbFieldRule {
1021            group_ahb_status: None,
1022            segment_path: segment_path.to_string(),
1023            name: name.to_string(),
1024            ahb_status: ahb_status.to_string(),
1025            codes: vec![],
1026            parent_group_ahb_status: None,
1027            segment_ahb_status: None,
1028            element_index,
1029            component_index,
1030            mig_number: mig_number.map(|s| s.to_string()),
1031            format: None,
1032        }
1033    }
1034
1035    #[test]
1036    fn test_empty_workflow_empty_tree() {
1037        let workflow = empty_workflow();
1038        let tree = empty_tree();
1039        let result = build_validated_tree(&workflow, &tree);
1040
1041        assert_eq!(result.pruefidentifikator, "11001");
1042        assert!(result.root_fields.is_empty());
1043        assert!(result.groups.is_empty());
1044    }
1045
1046    #[test]
1047    fn test_root_field_matches_root_segment() {
1048        let mut workflow = empty_workflow();
1049        workflow.fields.push(make_rule(
1050            "BGM/C002/1001",
1051            "Nachrichtentyp",
1052            "X",
1053            Some("0001"),
1054            Some(0),
1055            Some(0),
1056        ));
1057
1058        let tree = AssembledTree {
1059            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
1060            groups: vec![],
1061            post_group_start: 1,
1062            inter_group_segments: BTreeMap::new(),
1063        };
1064
1065        let result = build_validated_tree(&workflow, &tree);
1066
1067        assert_eq!(result.root_fields.len(), 1);
1068        let node = &result.root_fields[0];
1069        assert_eq!(node.value, Some("E01"));
1070        assert!(node.segment_elements.is_some());
1071        assert_eq!(node.rule.name, "Nachrichtentyp");
1072    }
1073
1074    #[test]
1075    fn test_sg4_dtm_mig_number_matching() {
1076        // Two DTM segments with different mig_numbers.
1077        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
1078        let mut workflow = empty_workflow();
1079
1080        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
1081        workflow.fields.push(make_rule(
1082            "SG4/DTM/C507/2380",
1083            "Eingangsdatum",
1084            "X",
1085            Some("0082"),
1086            Some(1), // date value is element 1
1087            Some(0),
1088        ));
1089
1090        // Rule for DTM+137.
1091        workflow.fields.push(make_rule(
1092            "SG4/DTM/C507/2380",
1093            "Dokumentendatum",
1094            "X",
1095            Some("0083"),
1096            Some(1),
1097            Some(0),
1098        ));
1099
1100        let tree = AssembledTree {
1101            segments: vec![],
1102            groups: vec![AssembledGroup {
1103                group_id: "SG4".to_string(),
1104                repetitions: vec![AssembledGroupInstance {
1105                    segments: vec![
1106                        make_segment("DTM", vec![vec!["92"], vec!["20260101"]], Some("0082")),
1107                        make_segment("DTM", vec![vec!["137"], vec!["20260401"]], Some("0083")),
1108                    ],
1109                    child_groups: vec![],
1110                    entry_mig_number: None,
1111                    variant_mig_numbers: vec![],
1112                    skipped_segments: vec![],
1113                    skipped_positions: Vec::new(),
1114                }],
1115            }],
1116            post_group_start: 0,
1117            inter_group_segments: BTreeMap::new(),
1118        };
1119
1120        let result = build_validated_tree(&workflow, &tree);
1121
1122        assert_eq!(result.groups.len(), 1);
1123        let sg4 = &result.groups[0];
1124        assert_eq!(sg4.group_id, "SG4");
1125        assert_eq!(sg4.fields.len(), 2);
1126
1127        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
1128        let eingangsdatum = &sg4.fields[0];
1129        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
1130        assert_eq!(eingangsdatum.value, Some("20260101"));
1131
1132        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
1133        let dokumentendatum = &sg4.fields[1];
1134        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
1135        assert_eq!(dokumentendatum.value, Some("20260401"));
1136    }
1137
1138    #[test]
1139    fn test_rule_filtered_to_correct_variant() {
1140        // A rule with mig_number "0099" is filtered out from an instance
1141        // that doesn't contain any segment with that mig_number.
1142        // This prevents false missing-field errors for wrong SG8 variants.
1143        let mut workflow = empty_workflow();
1144        workflow.fields.push(make_rule(
1145            "SG4/RFF/C506/1154",
1146            "Referenz",
1147            "X",
1148            Some("0099"),
1149            Some(0),
1150            Some(1),
1151        ));
1152
1153        let tree = AssembledTree {
1154            segments: vec![],
1155            groups: vec![AssembledGroup {
1156                group_id: "SG4".to_string(),
1157                repetitions: vec![AssembledGroupInstance {
1158                    segments: vec![], // No segments — wrong variant
1159                    child_groups: vec![],
1160                    entry_mig_number: None,
1161                    variant_mig_numbers: vec![],
1162                    skipped_segments: vec![],
1163                    skipped_positions: Vec::new(),
1164                }],
1165            }],
1166            post_group_start: 0,
1167            inter_group_segments: BTreeMap::new(),
1168        };
1169
1170        let result = build_validated_tree(&workflow, &tree);
1171        assert_eq!(result.groups.len(), 1);
1172        // Rule is filtered out — its mig_number doesn't match this instance
1173        assert_eq!(result.groups[0].fields.len(), 0);
1174    }
1175
1176    #[test]
1177    fn test_missing_segment_within_correct_variant() {
1178        // A rule with mig_number "0099" IS included when the instance's
1179        // variant_mig_numbers lists it — even though the segment is absent.
1180        // This enables missing-field detection within the correct variant.
1181        let mut workflow = empty_workflow();
1182        // Entry segment rule (present)
1183        workflow.fields.push(make_rule(
1184            "SG4/SEQ/1229",
1185            "Qualifier",
1186            "X",
1187            Some("0098"),
1188            Some(0),
1189            Some(0),
1190        ));
1191        // Second segment rule (absent — should report missing)
1192        workflow.fields.push(make_rule(
1193            "SG4/RFF/C506/1154",
1194            "Referenz",
1195            "X",
1196            Some("0099"),
1197            Some(0),
1198            Some(1),
1199        ));
1200
1201        let tree = AssembledTree {
1202            segments: vec![],
1203            groups: vec![AssembledGroup {
1204                group_id: "SG4".to_string(),
1205                repetitions: vec![AssembledGroupInstance {
1206                    segments: vec![
1207                        // Only the entry segment — RFF is missing
1208                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
1209                    ],
1210                    child_groups: vec![],
1211                    entry_mig_number: Some("0098".to_string()),
1212                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
1213                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
1214                    skipped_segments: vec![],
1215                    skipped_positions: Vec::new(),
1216                }],
1217            }],
1218            post_group_start: 0,
1219            inter_group_segments: BTreeMap::new(),
1220        };
1221
1222        let result = build_validated_tree(&workflow, &tree);
1223        assert_eq!(result.groups.len(), 1);
1224        // Both rules match because variant_mig_numbers includes both
1225        assert_eq!(result.groups[0].fields.len(), 2);
1226        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
1227        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
1228        // RFF is missing — value is None (condition eval will report it)
1229        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
1230        assert_eq!(result.groups[0].fields[1].value, None);
1231    }
1232
1233    #[test]
1234    fn test_missing_group_variant_populates_unmatched_rules() {
1235        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
1236        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
1237        // so validate_tree can report them as missing using flat logic.
1238        let mut workflow = empty_workflow();
1239
1240        // NAD+MS qualifier rule
1241        workflow.fields.push(make_rule(
1242            "SG2/NAD/3035",
1243            "MP-ID Absender Qualifier",
1244            "X",
1245            Some("0010"),
1246            Some(0),
1247            Some(0),
1248        ));
1249        // NAD+MS ID rule
1250        workflow.fields.push(make_rule(
1251            "SG2/NAD/C082/3039",
1252            "MP-ID Absender",
1253            "X",
1254            Some("0010"),
1255            Some(1),
1256            Some(0),
1257        ));
1258        // NAD+MR qualifier rule
1259        workflow.fields.push(make_rule(
1260            "SG2/NAD/3035",
1261            "MP-ID Empfänger Qualifier",
1262            "X",
1263            Some("0011"),
1264            Some(0),
1265            Some(0),
1266        ));
1267        // NAD+MR ID rule
1268        workflow.fields.push(make_rule(
1269            "SG2/NAD/C082/3039",
1270            "MP-ID Empfänger",
1271            "X",
1272            Some("0011"),
1273            Some(1),
1274            Some(0),
1275        ));
1276
1277        // Only NAD+MR present in assembled tree.
1278        let tree = AssembledTree {
1279            segments: vec![],
1280            groups: vec![AssembledGroup {
1281                group_id: "SG2".to_string(),
1282                repetitions: vec![AssembledGroupInstance {
1283                    segments: vec![make_segment(
1284                        "NAD",
1285                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
1286                        Some("0011"),
1287                    )],
1288                    child_groups: vec![],
1289                    entry_mig_number: Some("0011".to_string()),
1290                    variant_mig_numbers: vec!["0011".to_string()],
1291                    skipped_segments: vec![],
1292                    skipped_positions: Vec::new(),
1293                }],
1294            }],
1295            post_group_start: 0,
1296            inter_group_segments: BTreeMap::new(),
1297        };
1298
1299        let result = build_validated_tree(&workflow, &tree);
1300
1301        // Tree should have 1 SG2 group node (NAD+MR).
1302        assert_eq!(result.groups.len(), 1);
1303        assert_eq!(result.groups[0].fields.len(), 2);
1304        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
1305
1306        // NAD+MS rules should be in unmatched_rules.
1307        assert_eq!(
1308            result.unmatched_rules.len(),
1309            2,
1310            "Expected 2 unmatched rules (NAD+MS), got {}",
1311            result.unmatched_rules.len()
1312        );
1313        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1314        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
1315    }
1316
1317    #[test]
1318    fn test_entirely_absent_group_populates_unmatched_rules() {
1319        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
1320        let mut workflow = empty_workflow();
1321        workflow.fields.push(make_rule(
1322            "SG2/NAD/3035",
1323            "MP-ID Absender Qualifier",
1324            "X",
1325            Some("0010"),
1326            Some(0),
1327            Some(0),
1328        ));
1329
1330        let tree = empty_tree(); // No groups at all.
1331
1332        let result = build_validated_tree(&workflow, &tree);
1333
1334        // No tree nodes.
1335        assert!(result.groups.is_empty());
1336
1337        // The rule should be in unmatched_rules.
1338        assert_eq!(
1339            result.unmatched_rules.len(),
1340            1,
1341            "Expected 1 unmatched rule, got {}",
1342            result.unmatched_rules.len()
1343        );
1344        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1345    }
1346
1347    #[test]
1348    fn test_fallback_to_tag_when_no_mig_number() {
1349        let mut workflow = empty_workflow();
1350        workflow.fields.push(make_rule(
1351            "BGM/C002/1001",
1352            "Nachrichtentyp",
1353            "X",
1354            None, // No mig_number — should fall back to tag.
1355            Some(0),
1356            Some(0),
1357        ));
1358
1359        let tree = AssembledTree {
1360            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
1361            groups: vec![],
1362            post_group_start: 1,
1363            inter_group_segments: BTreeMap::new(),
1364        };
1365
1366        let result = build_validated_tree(&workflow, &tree);
1367        assert_eq!(result.root_fields.len(), 1);
1368        assert_eq!(result.root_fields[0].value, Some("E01"));
1369    }
1370
1371    #[test]
1372    fn test_nested_child_groups() {
1373        let mut workflow = empty_workflow();
1374        // A rule in SG4/SG5.
1375        workflow.fields.push(make_rule(
1376            "SG4/SG5/LOC/C517/3225",
1377            "Marktlokations-ID",
1378            "X",
1379            Some("0050"),
1380            Some(0),
1381            Some(0),
1382        ));
1383
1384        let tree = AssembledTree {
1385            segments: vec![],
1386            groups: vec![AssembledGroup {
1387                group_id: "SG4".to_string(),
1388                repetitions: vec![AssembledGroupInstance {
1389                    segments: vec![],
1390                    entry_mig_number: None,
1391                    child_groups: vec![AssembledGroup {
1392                        group_id: "SG5".to_string(),
1393                        repetitions: vec![AssembledGroupInstance {
1394                            segments: vec![make_segment(
1395                                "LOC",
1396                                vec![vec!["DE00012345678"]],
1397                                Some("0050"),
1398                            )],
1399                            child_groups: vec![],
1400                            entry_mig_number: None,
1401                            variant_mig_numbers: vec![],
1402                            skipped_segments: vec![],
1403                            skipped_positions: Vec::new(),
1404                        }],
1405                    }],
1406                    variant_mig_numbers: vec![],
1407                    skipped_segments: vec![],
1408                    skipped_positions: Vec::new(),
1409                }],
1410            }],
1411            post_group_start: 0,
1412            inter_group_segments: BTreeMap::new(),
1413        };
1414
1415        let result = build_validated_tree(&workflow, &tree);
1416        assert_eq!(result.groups.len(), 1);
1417        let sg4 = &result.groups[0];
1418        assert_eq!(sg4.children.len(), 1);
1419
1420        let sg5 = &sg4.children[0];
1421        assert_eq!(sg5.group_id, "SG5");
1422        assert_eq!(sg5.fields.len(), 1);
1423        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
1424        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
1425    }
1426
1427    #[test]
1428    fn test_qualifier_aware_segment_matching_same_tag_variants() {
1429        // MSCONS SG10 has four STS variants (9015=Z33, Z32, Z34, Z40) with
1430        // mig_numbers 00035..00038. The assembler matches same-tag MIG slots
1431        // positionally, so when input contains STS+Z32++Z92 and STS+Z40++Z75,
1432        // the segments end up tagged with mig=00035 and mig=00036 (the first
1433        // two MIG slots) instead of mig=00036 and mig=00038.
1434        //
1435        // `find_segment` must therefore prefer a segment whose qualifier value
1436        // matches the rule's single-code constraint over the assembler's
1437        // positional mig_number. Otherwise:
1438        //   * rule for Z33 (mig 00035) attaches to the Z32 segment and looks
1439        //     "present" with the wrong value, suppressing the genuine absence;
1440        //   * rule for Z40 (mig 00038) finds no segment and reports the Z40
1441        //     STS as "missing" even though it is present in the input.
1442        let mut workflow = empty_workflow();
1443
1444        // Z33 variant — not present in input, must resolve to None.
1445        let mut z33 = make_rule(
1446            "SG10/STS/C601/9015",
1447            "Statuskategorie Z33",
1448            "X",
1449            Some("00035"),
1450            Some(0),
1451            Some(0),
1452        );
1453        z33.codes = vec![super::super::validate::AhbCodeRule {
1454            value: "Z33".into(),
1455            description: String::new(),
1456            ahb_status: "X".into(),
1457        }];
1458        workflow.fields.push(z33);
1459
1460        // Z32 variant — present in input.
1461        let mut z32 = make_rule(
1462            "SG10/STS/C601/9015",
1463            "Statuskategorie Z32",
1464            "X",
1465            Some("00036"),
1466            Some(0),
1467            Some(0),
1468        );
1469        z32.codes = vec![super::super::validate::AhbCodeRule {
1470            value: "Z32".into(),
1471            description: String::new(),
1472            ahb_status: "X".into(),
1473        }];
1474        workflow.fields.push(z32);
1475
1476        // Z40 variant — present in input.
1477        let mut z40 = make_rule(
1478            "SG10/STS/C601/9015",
1479            "Statuskategorie Z40",
1480            "X",
1481            Some("00038"),
1482            Some(0),
1483            Some(0),
1484        );
1485        z40.codes = vec![super::super::validate::AhbCodeRule {
1486            value: "Z40".into(),
1487            description: String::new(),
1488            ahb_status: "X".into(),
1489        }];
1490        workflow.fields.push(z40);
1491
1492        // Assembler assigned mig=00035 to the Z32 segment and mig=00036 to the Z40
1493        // segment (positional), mimicking the real MSCONS 13025 pipeline output.
1494        let tree = AssembledTree {
1495            segments: vec![],
1496            groups: vec![AssembledGroup {
1497                group_id: "SG10".to_string(),
1498                repetitions: vec![AssembledGroupInstance {
1499                    segments: vec![
1500                        make_segment("STS", vec![vec!["Z32"], vec![], vec!["Z92"]], Some("00035")),
1501                        make_segment("STS", vec![vec!["Z40"], vec![], vec!["Z75"]], Some("00036")),
1502                    ],
1503                    child_groups: vec![],
1504                    entry_mig_number: None,
1505                    variant_mig_numbers: vec![
1506                        "00035".into(),
1507                        "00036".into(),
1508                        "00037".into(),
1509                        "00038".into(),
1510                    ],
1511                    skipped_segments: vec![],
1512                    skipped_positions: Vec::new(),
1513                }],
1514            }],
1515            post_group_start: 0,
1516            inter_group_segments: BTreeMap::new(),
1517        };
1518
1519        let result = build_validated_tree(&workflow, &tree);
1520        assert_eq!(result.groups.len(), 1);
1521        let sg10 = &result.groups[0];
1522        assert_eq!(
1523            sg10.fields.len(),
1524            3,
1525            "all three rules should pass the variant filter"
1526        );
1527
1528        let by_name = |name: &str| sg10.fields.iter().find(|f| f.rule.name == name).unwrap();
1529
1530        // Z33 is genuinely absent — no STS in the input has 9015=Z33.
1531        assert_eq!(
1532            by_name("Statuskategorie Z33").value,
1533            None,
1534            "Z33 has no matching STS in the input; must not silently pick up Z32's segment"
1535        );
1536
1537        // Z32 must resolve to the STS segment whose 9015 is Z32,
1538        // regardless of the assembler's mis-assigned mig_number.
1539        assert_eq!(
1540            by_name("Statuskategorie Z32").value,
1541            Some("Z32"),
1542            "rule for Z32 must attach to the STS segment with qualifier Z32"
1543        );
1544
1545        // Z40 must resolve to the STS segment whose 9015 is Z40.
1546        assert_eq!(
1547            by_name("Statuskategorie Z40").value,
1548            Some("Z40"),
1549            "rule for Z40 must attach to the STS segment with qualifier Z40"
1550        );
1551    }
1552}