Skip to main content

automapper_validation/validator/
tree.rs

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