Skip to main content

automapper_validation/validator/
tree.rs

1//! Merged AHB + EDIFACT tree for validation.
2//!
3//! [`build_validated_tree`] joins an [`AhbWorkflow`] (what should be there)
4//! with an [`AssembledTree`] (what is there) into a single [`ValidatedTree`]
5//! where each node carries both the AHB rule and the resolved EDIFACT value.
6
7use std::collections::{BTreeMap, HashMap};
8
9use crate::expr::ConditionExpr;
10
11use super::validate::{AhbFieldRule, AhbWorkflow};
12use mig_assembly::assembler::{
13    AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
14};
15
16/// A single AHB field matched against EDIFACT data.
17#[derive(Debug)]
18pub struct AhbNode<'a> {
19    /// The AHB field rule (segment path, ahb_status, codes, etc.)
20    pub rule: &'a AhbFieldRule,
21    /// The actual value found in the EDIFACT at this position. `None` if absent.
22    pub value: Option<&'a str>,
23    /// The full segment elements for cross-element access. `None` if segment absent.
24    pub segment_elements: Option<&'a [Vec<String>]>,
25    /// Source segment counter of the matched [`AssembledSegment`], when known.
26    /// Used by issue construction to populate `segment_position`.
27    pub matched_segment_number: Option<u32>,
28}
29
30/// A segment group instance matched against EDIFACT data.
31#[derive(Debug)]
32pub struct AhbGroupNode<'a> {
33    /// Group ID (e.g., "SG4", "SG8").
34    pub group_id: &'a str,
35    /// Which repetition of `group_id` this is, among its siblings.
36    pub instance_index: usize,
37    /// AHB status of this group.
38    pub ahb_status: Option<&'a str>,
39    /// Fields in this group instance, resolved against EDIFACT segments.
40    pub fields: Vec<AhbNode<'a>>,
41    /// Child group instances.
42    pub children: Vec<AhbGroupNode<'a>>,
43    /// 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);
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    rules
285        .iter()
286        .map(|rule| {
287            let matched_segment = find_segment(rule, segments, numbers);
288            let (value, elements, segment_number) = match matched_segment {
289                Some(seg) => {
290                    let val = extract_value(seg, rule);
291                    (val, Some(seg.elements.as_slice()), seg.segment_number)
292                }
293                None => {
294                    // Best-effort segment counter fallback: when find_segment
295                    // returns None because the expected qualifier is absent
296                    // (e.g., a Muss-313 format code missing from a DTM
297                    // segment that IS present), still surface the counter of
298                    // the same-tag segment in this scope so a CONTRL can
299                    // point to the right line. We don't borrow value/elements
300                    // because the variant truly is absent — only the counter
301                    // is meaningful.
302                    let fallback_num = extract_segment_tag(&rule.segment_path)
303                        .and_then(|tag| segments.iter().find(|s| s.tag == tag))
304                        .and_then(|s| s.segment_number);
305                    (None, None, fallback_num)
306                }
307            };
308            AhbNode {
309                rule,
310                value,
311                segment_elements: elements,
312                matched_segment_number: segment_number,
313            }
314        })
315        .collect()
316}
317
318/// Find the assembled segment matching an AHB field rule.
319///
320/// Matching strategy when the rule has a `mig_number`:
321/// 1. **Strict match**: find a segment with the exact `mig_number`.
322/// 2. **Tag fallback**: if no segment has that `mig_number`, fall back to tag
323///    matching — but ONLY against segments that don't have a *different*
324///    `mig_number` assigned. This prevents cross-matching between same-tag
325///    segments with known identities (e.g., DTM+92 rules must not match a
326///    DTM+93 segment that has `mig_number` "00024").
327/// 3. Segments with `mig_number: None` (unattributed) are eligible for the
328///    tag fallback, preserving backward compatibility with greedy assembly.
329fn find_segment<'a>(
330    rule: &AhbFieldRule,
331    segments: &'a [AssembledSegment],
332    numbers: Option<&[Option<String>]>,
333) -> Option<&'a AssembledSegment> {
334    // A segment's MIG number: its own, or the one `numbers` assigns it.
335    let number_of = |i: usize, s: &'a AssembledSegment| -> Option<&str> {
336        match numbers {
337            Some(n) => n.get(i).and_then(|n| n.as_deref()),
338            None => s.mig_number.as_deref(),
339        }
340    };
341    let tag = extract_segment_tag(&rule.segment_path)?;
342
343    // Qualifier-aware match: when the rule's codes identify a segment
344    // variant (e.g. `STS/C601/9015` with codes=[Z32]), pick the segment
345    // whose value at the rule's position matches one of those codes.
346    // This overrides any mig_number that the assembler may have
347    // mis-assigned positionally to same-tag variants sharing a single
348    // group instance (e.g. MSCONS SG10 carries STS+Z32 *and* STS+Z40,
349    // and the assembler tags them by MIG slot order, not by qualifier).
350    //
351    // When *no* segment in the instance carries a matching qualifier,
352    // the outcome depends on how many same-tag segments are in view:
353    //   * 2+ same-tag segments → real contention between variants; the
354    //     rule's variant is genuinely absent from this instance, so
355    //     return None and let condition evaluation decide if that's an
356    //     error.
357    //   * 0–1 same-tag segment → no contention. Fall through to the
358    //     mig_number / tag match below to preserve behavior for merged
359    //     entry slots where multiple variants share one mig_number
360    //     (MSCONS SG2 collapses NAD+MS and NAD+MR onto mig=00013).
361    if !rule.codes.is_empty() {
362        let el_idx = rule.element_index.unwrap_or(0);
363        let comp_idx = rule.component_index.unwrap_or(0);
364        let expected: std::collections::HashSet<&str> =
365            rule.codes.iter().map(|c| c.value.as_str()).collect();
366        if let Some(seg) = segments.iter().find(|s| {
367            s.tag == tag
368                && s.elements
369                    .get(el_idx)
370                    .and_then(|e| e.get(comp_idx))
371                    .is_some_and(|v| expected.contains(v.as_str()))
372        }) {
373            return Some(seg);
374        }
375        let same_tag_count = segments.iter().filter(|s| s.tag == tag).count();
376        if same_tag_count > 1 {
377            return None;
378        }
379        // One same-tag segment that is not the group's entry and carries a
380        // different qualifier is another slot's (QUOTES 15001 SG27: a
381        // position with only `DTM+Z03` Baujahr has no `DTM+Z04`
382        // Eichgültigkeit). Only an entry segment may stand for a merged
383        // variant whose qualifier it does not carry.
384        let other_slot = segments.iter().enumerate().any(|(i, s)| {
385            i > 0
386                && s.tag == tag
387                && s.elements
388                    .get(el_idx)
389                    .and_then(|e| e.get(comp_idx))
390                    .is_some_and(|v| !v.is_empty() && !expected.contains(v.as_str()))
391        });
392        if other_slot {
393            return None;
394        }
395    }
396
397    if let Some(mig_num) = rule.mig_number.as_ref() {
398        // 1. Strict mig_number match.
399        if let Some((_, seg)) = segments
400            .iter()
401            .enumerate()
402            .find(|(i, s)| number_of(*i, s) == Some(mig_num.as_str()))
403        {
404            return Some(seg);
405        }
406
407        // 2. Tag fallback, excluding segments with a conflicting mig_number.
408        return segments
409            .iter()
410            .enumerate()
411            .find(|(i, s)| s.tag == tag && number_of(*i, s).map_or(true, |m| m == mig_num))
412            .map(|(_, s)| s);
413    }
414
415    // No mig_number on rule — pure tag match.
416    segments.iter().find(|s| s.tag == tag)
417}
418
419/// Extract the segment tag from a segment path.
420///
421/// `"SG4/DTM/C507/2380"` -> `"DTM"` (first non-SG component)
422/// `"BGM/1004"` -> `"BGM"`
423fn extract_segment_tag(segment_path: &str) -> Option<&str> {
424    segment_path.split('/').find(|part| !part.starts_with("SG"))
425}
426
427/// Extract a field value from an assembled segment using element/component indices.
428fn extract_value<'a>(segment: &'a AssembledSegment, rule: &AhbFieldRule) -> Option<&'a str> {
429    let elem_idx = rule.element_index.unwrap_or(0);
430    let comp_idx = rule.component_index.unwrap_or(0);
431
432    let element = segment.elements.get(elem_idx)?;
433    let component = element.get(comp_idx)?;
434
435    if component.is_empty() {
436        None
437    } else {
438        Some(component.as_str())
439    }
440}
441
442/// Resolve assembled groups against grouped AHB rules, recursively.
443///
444/// `depth` is the number of group prefixes to strip from each rule's
445/// `segment_path` before classifying it as direct or child.
446fn resolve_groups<'a>(
447    assembled_groups: &'a [AssembledGroup],
448    group_rules: &HashMap<String, Vec<&'a AhbFieldRule>>,
449    depth: usize,
450) -> Vec<AhbGroupNode<'a>> {
451    let mut result = Vec::new();
452
453    for assembled_group in assembled_groups {
454        let rules = group_rules.get(&assembled_group.group_id);
455
456        for (instance_index, instance) in assembled_group.repetitions.iter().enumerate() {
457            let mut node =
458                resolve_group_instance(&assembled_group.group_id, instance, rules, depth);
459            node.instance_index = instance_index;
460            result.push(node);
461        }
462    }
463
464    result
465}
466
467/// Strip `n` leading group prefixes from a segment path.
468///
469/// `strip_n_groups("SG4/SG5/LOC/3225", 2)` -> `"LOC/3225"`
470fn strip_n_groups(path: &str, n: usize) -> &str {
471    let mut rest = path;
472    for _ in 0..n {
473        match rest.find('/') {
474            Some(idx) => rest = &rest[idx + 1..],
475            None => return rest,
476        }
477    }
478    rest
479}
480
481/// Resolve a single group instance.
482///
483/// `depth` is how many group prefixes have been consumed so far (0 for top-level groups).
484///
485/// Rules are filtered to this instance by `mig_number`: a rule only applies if
486/// its `mig_number` matches a segment in this instance (or a segment in a child
487/// group instance). This prevents rules for SG8/SEQ+Z79 from generating false
488/// missing-field errors against an SG8/SEQ+Z01 rep.
489fn resolve_group_instance<'a>(
490    group_id: &'a str,
491    instance: &'a AssembledGroupInstance,
492    rules: Option<&Vec<&'a AhbFieldRule>>,
493    depth: usize,
494) -> AhbGroupNode<'a> {
495    // We need to strip (depth + 1) group prefixes to get below this group level.
496    let strip_count = depth + 1;
497
498    // Use the variant's full set of mig_numbers (from MIG definition) to
499    // determine which rules belong to this instance. This includes numbers
500    // for segments that may be absent — so missing-field detection still works.
501    // Falls back to collecting from present segments if variant_mig_numbers is empty.
502    let variant_numbers: std::collections::HashSet<&str> =
503        if !instance.variant_mig_numbers.is_empty() {
504            instance
505                .variant_mig_numbers
506                .iter()
507                .map(|s| s.as_str())
508                .collect()
509        } else {
510            collect_instance_mig_numbers(instance)
511        };
512
513    let mut direct_rules: Vec<&'a AhbFieldRule> = Vec::new();
514    let mut child_group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
515    let mut ahb_status: Option<&'a str> = None;
516
517    // A group merged from several variants (QUOTES SG28: CCI+++E13 00029,
518    // CCI+++Z75 00042, …) carries one variant's MIG numbers and entry for
519    // every instance. The instance's own variant is the one whose entry code
520    // its entry segment carries; only that variant's rules apply.
521    let merged = rules.and_then(|rules| {
522        let own: Vec<&'a AhbFieldRule> = rules
523            .iter()
524            .copied()
525            .filter(|r| extract_child_group(strip_n_groups(&r.segment_path, strip_count)).is_none())
526            .collect();
527        merged_variant(&own, instance)
528    });
529
530    // The span of this instance's MIG numbers. A child group's rules belong
531    // to it when they lie inside: variants occupy contiguous MIG ranges, and
532    // the instance's own numbers name only one variant of a merged child
533    // (QUOTES SG28 keeps CCI+++Z75's), not all of them.
534    let span = variant_numbers
535        .iter()
536        .filter_map(|n| n.parse::<u32>().ok())
537        .fold(None, |acc: Option<(u32, u32)>, n| {
538            Some(acc.map_or((n, n), |(lo, hi)| (lo.min(n), hi.max(n))))
539        });
540
541    if let Some(rules) = rules {
542        for rule in rules {
543            // Strip all parent group prefixes plus this group to get the relative path.
544            let stripped = strip_n_groups(&rule.segment_path, strip_count);
545            let child = extract_child_group(stripped);
546
547            // Skip rules whose mig_number doesn't belong to this variant.
548            // Rules without mig_number pass through (tag-based fallback).
549            if let Some(ref rule_mig) = rule.mig_number {
550                let number = rule_mig.parse::<u32>().ok();
551                let belongs = match (&merged, child) {
552                    (Some((_, range)), _) => number.is_some_and(|m| range.contains(&m)),
553                    (None, Some(_)) => match (number, span) {
554                        (Some(m), Some((lo, hi))) => (lo..=hi).contains(&m),
555                        _ => variant_numbers.contains(rule_mig.as_str()),
556                    },
557                    (None, None) => variant_numbers.contains(rule_mig.as_str()),
558                };
559                if !belongs {
560                    continue;
561                }
562            }
563
564            if let Some(child_group_id) = child {
565                child_group_rules
566                    .entry(child_group_id.to_owned())
567                    .or_default()
568                    .push(rule);
569            } else {
570                direct_rules.push(rule);
571            }
572        }
573    }
574    // The group's status is its variant's: read from the rule of the instance's
575    // entry segment, else from any rule of this group proper with a MIG number
576    // of the instance — never a child group's rule (it carries the child's
577    // status) or a rule without a MIG number (it may be another variant's).
578    let own_status = |rule: &&'a AhbFieldRule| rule.parent_group_ahb_status.as_deref();
579    let entry = merged
580        .as_ref()
581        .map(|(entry, _)| *entry)
582        .or(instance.entry_mig_number.as_deref());
583    if let Some(status) = direct_rules
584        .iter()
585        .filter(|r| entry.is_some() && r.mig_number.as_deref() == entry)
586        .find_map(own_status)
587        .or_else(|| {
588            direct_rules
589                .iter()
590                .filter(|r| r.mig_number.is_some())
591                .find_map(own_status)
592        })
593    {
594        ahb_status = Some(status);
595    }
596
597    // Resolve direct fields against instance segments.
598    // The segments of a merged group's instance carry the first variant's MIG
599    // numbers, not their own: per tag, the n-th segment takes the variant's
600    // n-th slot (the assembler fills slots in MIG order too).
601    let numbers: Option<Vec<Option<String>>> = merged.as_ref().map(|_| {
602        let mut used: Vec<&str> = Vec::new();
603        instance
604            .segments
605            .iter()
606            .map(|seg| {
607                let of_tag = |r: &&&'a AhbFieldRule| {
608                    extract_segment_tag(&r.segment_path) == Some(seg.tag.as_str())
609                };
610                let mut slots: Vec<&str> = direct_rules
611                    .iter()
612                    .filter(of_tag)
613                    .filter_map(|r| r.mig_number.as_deref())
614                    .filter(|n| !used.contains(n))
615                    .collect();
616                slots.sort_by_key(|n| n.parse::<u32>().unwrap_or(u32::MAX));
617                slots.dedup();
618                // The slot whose codes the segment carries; else the first
619                // slot that lists no codes; else the next one in MIG order.
620                let coded = |slot: &str| {
621                    direct_rules
622                        .iter()
623                        .filter(of_tag)
624                        .filter(|r| r.mig_number.as_deref() == Some(slot) && !r.codes.is_empty())
625                        .collect::<Vec<_>>()
626                };
627                let carries = |slot: &&str| {
628                    coded(slot).iter().any(|r| {
629                        seg.elements
630                            .get(r.element_index.unwrap_or(0))
631                            .and_then(|e| e.get(r.component_index.unwrap_or(0)))
632                            .is_some_and(|v| r.codes.iter().any(|c| &c.value == v))
633                    })
634                };
635                let slot = slots
636                    .iter()
637                    .find(|s| carries(s))
638                    .or_else(|| slots.iter().find(|s| coded(s).is_empty()))
639                    .or_else(|| slots.first())
640                    .copied();
641                if let Some(slot) = slot {
642                    used.push(slot);
643                }
644                slot.map(str::to_string)
645            })
646            .collect()
647    });
648    let fields = resolve_fields(&direct_rules, &instance.segments, numbers.as_deref());
649
650    // Recurse into child groups one level deeper.
651    let children = resolve_groups(&instance.child_groups, &child_group_rules, strip_count);
652
653    let mut absent_children = Vec::new();
654    for (child_id, rules) in &child_group_rules {
655        absent_children.extend(absent_variant_rules(
656            child_id,
657            rules,
658            &children,
659            strip_count + 1,
660        ));
661    }
662    absent_children.sort_by_key(|r| r.mig_number.as_deref().and_then(|n| n.parse::<u32>().ok()));
663    let mut variant_rules = direct_rules;
664    variant_rules.extend(child_group_rules.into_values().flatten());
665
666    AhbGroupNode {
667        group_id,
668        instance_index: 0,
669        ahb_status,
670        fields,
671        children,
672        variant_rules,
673        absent_children,
674    }
675}
676
677/// The rules of `child_id`'s variants that no instance among `children`
678/// claims: for each, the rules of its own segments. `rules` are every rule
679/// below this instance under `child_id`; `strip` the group prefixes to strip
680/// to reach the child's own segments.
681///
682/// Variants are told apart by their entry segment's qualifier, as in
683/// [`merged_variant`]: the entry tag is that of the child's first rule in MIG
684/// order, each variant starts at an entry rule coding the qualifier, and the
685/// codes of all variants must be disjoint (`SG8 SEQ+Z01`, `SEQ+Z02`, …). A
686/// child whose variants share or lack such codes (SG10 `CCI` variants told
687/// apart by DE7037) yields nothing.
688fn absent_variant_rules<'a>(
689    child_id: &str,
690    rules: &[&'a AhbFieldRule],
691    children: &[AhbGroupNode<'a>],
692    strip: usize,
693) -> Vec<&'a AhbFieldRule> {
694    let number = |r: &AhbFieldRule| r.mig_number.as_deref()?.parse::<u32>().ok();
695    let own: Vec<&'a AhbFieldRule> = rules
696        .iter()
697        .copied()
698        .filter(|r| extract_child_group(strip_n_groups(&r.segment_path, strip)).is_none())
699        .filter(|r| number(r).is_some())
700        .collect();
701    let Some(entry_tag) = own
702        .iter()
703        .min_by_key(|r| number(r))
704        .and_then(|r| extract_segment_tag(&r.segment_path))
705    else {
706        return Vec::new();
707    };
708    let is_entry_tag = |r: &AhbFieldRule| extract_segment_tag(&r.segment_path) == Some(entry_tag);
709    let mut entries: Vec<(u32, &'a AhbFieldRule)> = own
710        .iter()
711        .copied()
712        .filter(|r| {
713            is_entry_tag(r)
714                && r.element_index.unwrap_or(0) == 0
715                && r.component_index.unwrap_or(0) == 0
716        })
717        .filter_map(|r| Some((number(r)?, r)))
718        .collect();
719    entries.sort_by_key(|(n, _)| *n);
720    entries.dedup_by_key(|(n, _)| *n);
721    let starts: std::collections::BTreeSet<u32> = own
722        .iter()
723        .filter(|r| is_entry_tag(r))
724        .filter_map(|r| number(r))
725        .collect();
726    let disjoint = entries.len() == starts.len()
727        && entries.iter().all(|(_, r)| !r.codes.is_empty())
728        && entries.iter().enumerate().all(|(i, (_, a))| {
729            entries[i + 1..].iter().all(|(_, b)| {
730                !a.codes
731                    .iter()
732                    .any(|c| b.codes.iter().any(|d| d.value == c.value))
733            })
734        });
735    if !disjoint {
736        return Vec::new();
737    }
738    let claimed: std::collections::HashSet<&str> = children
739        .iter()
740        .filter(|c| c.group_id == child_id)
741        .flat_map(|c| c.fields.iter().filter_map(|n| n.rule.mig_number.as_deref()))
742        .collect();
743    let mut out = Vec::new();
744    for (i, (start, entry)) in entries.iter().enumerate() {
745        if claimed.contains(entry.mig_number.as_deref().unwrap_or_default()) {
746            continue;
747        }
748        let end = entries.get(i + 1).map_or(u32::MAX, |(n, _)| *n);
749        out.extend(
750            own.iter()
751                .copied()
752                .filter(|r| number(r).is_some_and(|n| (*start..end).contains(&n))),
753        );
754    }
755    out
756}
757
758/// Collect all mig_numbers present in a group instance, including child groups recursively.
759fn collect_instance_mig_numbers(
760    instance: &AssembledGroupInstance,
761) -> std::collections::HashSet<&str> {
762    let mut numbers = std::collections::HashSet::new();
763    for seg in &instance.segments {
764        if let Some(ref num) = seg.mig_number {
765            numbers.insert(num.as_str());
766        }
767    }
768    for child_group in &instance.child_groups {
769        for child_instance in &child_group.repetitions {
770            numbers.extend(collect_instance_mig_numbers(child_instance));
771        }
772    }
773    numbers
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::validator::validate::{AhbFieldRule, AhbWorkflow};
780    use mig_assembly::assembler::{
781        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
782    };
783    use std::collections::BTreeMap;
784
785    fn empty_workflow() -> AhbWorkflow {
786        AhbWorkflow {
787            pruefidentifikator: "11001".to_string(),
788            description: String::new(),
789            communication_direction: None,
790            fields: vec![],
791            ub_definitions: BTreeMap::new(),
792            package_conditions: Default::default(),
793        }
794    }
795
796    fn empty_tree() -> AssembledTree {
797        AssembledTree {
798            segments: vec![],
799            groups: vec![],
800            post_group_start: 0,
801            inter_group_segments: BTreeMap::new(),
802        }
803    }
804
805    fn make_segment(
806        tag: &str,
807        elements: Vec<Vec<&str>>,
808        mig_number: Option<&str>,
809    ) -> AssembledSegment {
810        AssembledSegment {
811            tag: tag.to_string(),
812            elements: elements
813                .into_iter()
814                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
815                .collect(),
816            mig_number: mig_number.map(|s| s.to_string()),
817            segment_number: None,
818        }
819    }
820
821    fn make_rule(
822        segment_path: &str,
823        name: &str,
824        ahb_status: &str,
825        mig_number: Option<&str>,
826        element_index: Option<usize>,
827        component_index: Option<usize>,
828    ) -> AhbFieldRule {
829        AhbFieldRule {
830            segment_path: segment_path.to_string(),
831            name: name.to_string(),
832            ahb_status: ahb_status.to_string(),
833            codes: vec![],
834            parent_group_ahb_status: None,
835            segment_ahb_status: None,
836            element_index,
837            component_index,
838            mig_number: mig_number.map(|s| s.to_string()),
839            format: None,
840        }
841    }
842
843    #[test]
844    fn test_empty_workflow_empty_tree() {
845        let workflow = empty_workflow();
846        let tree = empty_tree();
847        let result = build_validated_tree(&workflow, &tree);
848
849        assert_eq!(result.pruefidentifikator, "11001");
850        assert!(result.root_fields.is_empty());
851        assert!(result.groups.is_empty());
852    }
853
854    #[test]
855    fn test_root_field_matches_root_segment() {
856        let mut workflow = empty_workflow();
857        workflow.fields.push(make_rule(
858            "BGM/C002/1001",
859            "Nachrichtentyp",
860            "X",
861            Some("0001"),
862            Some(0),
863            Some(0),
864        ));
865
866        let tree = AssembledTree {
867            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
868            groups: vec![],
869            post_group_start: 1,
870            inter_group_segments: BTreeMap::new(),
871        };
872
873        let result = build_validated_tree(&workflow, &tree);
874
875        assert_eq!(result.root_fields.len(), 1);
876        let node = &result.root_fields[0];
877        assert_eq!(node.value, Some("E01"));
878        assert!(node.segment_elements.is_some());
879        assert_eq!(node.rule.name, "Nachrichtentyp");
880    }
881
882    #[test]
883    fn test_sg4_dtm_mig_number_matching() {
884        // Two DTM segments with different mig_numbers.
885        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
886        let mut workflow = empty_workflow();
887
888        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
889        workflow.fields.push(make_rule(
890            "SG4/DTM/C507/2380",
891            "Eingangsdatum",
892            "X",
893            Some("0082"),
894            Some(1), // date value is element 1
895            Some(0),
896        ));
897
898        // Rule for DTM+137.
899        workflow.fields.push(make_rule(
900            "SG4/DTM/C507/2380",
901            "Dokumentendatum",
902            "X",
903            Some("0083"),
904            Some(1),
905            Some(0),
906        ));
907
908        let tree = AssembledTree {
909            segments: vec![],
910            groups: vec![AssembledGroup {
911                group_id: "SG4".to_string(),
912                repetitions: vec![AssembledGroupInstance {
913                    segments: vec![
914                        make_segment("DTM", vec![vec!["92"], vec!["20260101"]], Some("0082")),
915                        make_segment("DTM", vec![vec!["137"], vec!["20260401"]], Some("0083")),
916                    ],
917                    child_groups: vec![],
918                    entry_mig_number: None,
919                    variant_mig_numbers: vec![],
920                    skipped_segments: vec![],
921                    skipped_positions: Vec::new(),
922                }],
923            }],
924            post_group_start: 0,
925            inter_group_segments: BTreeMap::new(),
926        };
927
928        let result = build_validated_tree(&workflow, &tree);
929
930        assert_eq!(result.groups.len(), 1);
931        let sg4 = &result.groups[0];
932        assert_eq!(sg4.group_id, "SG4");
933        assert_eq!(sg4.fields.len(), 2);
934
935        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
936        let eingangsdatum = &sg4.fields[0];
937        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
938        assert_eq!(eingangsdatum.value, Some("20260101"));
939
940        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
941        let dokumentendatum = &sg4.fields[1];
942        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
943        assert_eq!(dokumentendatum.value, Some("20260401"));
944    }
945
946    #[test]
947    fn test_rule_filtered_to_correct_variant() {
948        // A rule with mig_number "0099" is filtered out from an instance
949        // that doesn't contain any segment with that mig_number.
950        // This prevents false missing-field errors for wrong SG8 variants.
951        let mut workflow = empty_workflow();
952        workflow.fields.push(make_rule(
953            "SG4/RFF/C506/1154",
954            "Referenz",
955            "X",
956            Some("0099"),
957            Some(0),
958            Some(1),
959        ));
960
961        let tree = AssembledTree {
962            segments: vec![],
963            groups: vec![AssembledGroup {
964                group_id: "SG4".to_string(),
965                repetitions: vec![AssembledGroupInstance {
966                    segments: vec![], // No segments — wrong variant
967                    child_groups: vec![],
968                    entry_mig_number: None,
969                    variant_mig_numbers: vec![],
970                    skipped_segments: vec![],
971                    skipped_positions: Vec::new(),
972                }],
973            }],
974            post_group_start: 0,
975            inter_group_segments: BTreeMap::new(),
976        };
977
978        let result = build_validated_tree(&workflow, &tree);
979        assert_eq!(result.groups.len(), 1);
980        // Rule is filtered out — its mig_number doesn't match this instance
981        assert_eq!(result.groups[0].fields.len(), 0);
982    }
983
984    #[test]
985    fn test_missing_segment_within_correct_variant() {
986        // A rule with mig_number "0099" IS included when the instance's
987        // variant_mig_numbers lists it — even though the segment is absent.
988        // This enables missing-field detection within the correct variant.
989        let mut workflow = empty_workflow();
990        // Entry segment rule (present)
991        workflow.fields.push(make_rule(
992            "SG4/SEQ/1229",
993            "Qualifier",
994            "X",
995            Some("0098"),
996            Some(0),
997            Some(0),
998        ));
999        // Second segment rule (absent — should report missing)
1000        workflow.fields.push(make_rule(
1001            "SG4/RFF/C506/1154",
1002            "Referenz",
1003            "X",
1004            Some("0099"),
1005            Some(0),
1006            Some(1),
1007        ));
1008
1009        let tree = AssembledTree {
1010            segments: vec![],
1011            groups: vec![AssembledGroup {
1012                group_id: "SG4".to_string(),
1013                repetitions: vec![AssembledGroupInstance {
1014                    segments: vec![
1015                        // Only the entry segment — RFF is missing
1016                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
1017                    ],
1018                    child_groups: vec![],
1019                    entry_mig_number: Some("0098".to_string()),
1020                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
1021                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
1022                    skipped_segments: vec![],
1023                    skipped_positions: Vec::new(),
1024                }],
1025            }],
1026            post_group_start: 0,
1027            inter_group_segments: BTreeMap::new(),
1028        };
1029
1030        let result = build_validated_tree(&workflow, &tree);
1031        assert_eq!(result.groups.len(), 1);
1032        // Both rules match because variant_mig_numbers includes both
1033        assert_eq!(result.groups[0].fields.len(), 2);
1034        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
1035        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
1036        // RFF is missing — value is None (condition eval will report it)
1037        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
1038        assert_eq!(result.groups[0].fields[1].value, None);
1039    }
1040
1041    #[test]
1042    fn test_missing_group_variant_populates_unmatched_rules() {
1043        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
1044        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
1045        // so validate_tree can report them as missing using flat logic.
1046        let mut workflow = empty_workflow();
1047
1048        // NAD+MS qualifier rule
1049        workflow.fields.push(make_rule(
1050            "SG2/NAD/3035",
1051            "MP-ID Absender Qualifier",
1052            "X",
1053            Some("0010"),
1054            Some(0),
1055            Some(0),
1056        ));
1057        // NAD+MS ID rule
1058        workflow.fields.push(make_rule(
1059            "SG2/NAD/C082/3039",
1060            "MP-ID Absender",
1061            "X",
1062            Some("0010"),
1063            Some(1),
1064            Some(0),
1065        ));
1066        // NAD+MR qualifier rule
1067        workflow.fields.push(make_rule(
1068            "SG2/NAD/3035",
1069            "MP-ID Empfänger Qualifier",
1070            "X",
1071            Some("0011"),
1072            Some(0),
1073            Some(0),
1074        ));
1075        // NAD+MR ID rule
1076        workflow.fields.push(make_rule(
1077            "SG2/NAD/C082/3039",
1078            "MP-ID Empfänger",
1079            "X",
1080            Some("0011"),
1081            Some(1),
1082            Some(0),
1083        ));
1084
1085        // Only NAD+MR present in assembled tree.
1086        let tree = AssembledTree {
1087            segments: vec![],
1088            groups: vec![AssembledGroup {
1089                group_id: "SG2".to_string(),
1090                repetitions: vec![AssembledGroupInstance {
1091                    segments: vec![make_segment(
1092                        "NAD",
1093                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
1094                        Some("0011"),
1095                    )],
1096                    child_groups: vec![],
1097                    entry_mig_number: Some("0011".to_string()),
1098                    variant_mig_numbers: vec!["0011".to_string()],
1099                    skipped_segments: vec![],
1100                    skipped_positions: Vec::new(),
1101                }],
1102            }],
1103            post_group_start: 0,
1104            inter_group_segments: BTreeMap::new(),
1105        };
1106
1107        let result = build_validated_tree(&workflow, &tree);
1108
1109        // Tree should have 1 SG2 group node (NAD+MR).
1110        assert_eq!(result.groups.len(), 1);
1111        assert_eq!(result.groups[0].fields.len(), 2);
1112        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
1113
1114        // NAD+MS rules should be in unmatched_rules.
1115        assert_eq!(
1116            result.unmatched_rules.len(),
1117            2,
1118            "Expected 2 unmatched rules (NAD+MS), got {}",
1119            result.unmatched_rules.len()
1120        );
1121        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1122        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
1123    }
1124
1125    #[test]
1126    fn test_entirely_absent_group_populates_unmatched_rules() {
1127        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
1128        let mut workflow = empty_workflow();
1129        workflow.fields.push(make_rule(
1130            "SG2/NAD/3035",
1131            "MP-ID Absender Qualifier",
1132            "X",
1133            Some("0010"),
1134            Some(0),
1135            Some(0),
1136        ));
1137
1138        let tree = empty_tree(); // No groups at all.
1139
1140        let result = build_validated_tree(&workflow, &tree);
1141
1142        // No tree nodes.
1143        assert!(result.groups.is_empty());
1144
1145        // The rule should be in unmatched_rules.
1146        assert_eq!(
1147            result.unmatched_rules.len(),
1148            1,
1149            "Expected 1 unmatched rule, got {}",
1150            result.unmatched_rules.len()
1151        );
1152        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1153    }
1154
1155    #[test]
1156    fn test_fallback_to_tag_when_no_mig_number() {
1157        let mut workflow = empty_workflow();
1158        workflow.fields.push(make_rule(
1159            "BGM/C002/1001",
1160            "Nachrichtentyp",
1161            "X",
1162            None, // No mig_number — should fall back to tag.
1163            Some(0),
1164            Some(0),
1165        ));
1166
1167        let tree = AssembledTree {
1168            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
1169            groups: vec![],
1170            post_group_start: 1,
1171            inter_group_segments: BTreeMap::new(),
1172        };
1173
1174        let result = build_validated_tree(&workflow, &tree);
1175        assert_eq!(result.root_fields.len(), 1);
1176        assert_eq!(result.root_fields[0].value, Some("E01"));
1177    }
1178
1179    #[test]
1180    fn test_nested_child_groups() {
1181        let mut workflow = empty_workflow();
1182        // A rule in SG4/SG5.
1183        workflow.fields.push(make_rule(
1184            "SG4/SG5/LOC/C517/3225",
1185            "Marktlokations-ID",
1186            "X",
1187            Some("0050"),
1188            Some(0),
1189            Some(0),
1190        ));
1191
1192        let tree = AssembledTree {
1193            segments: vec![],
1194            groups: vec![AssembledGroup {
1195                group_id: "SG4".to_string(),
1196                repetitions: vec![AssembledGroupInstance {
1197                    segments: vec![],
1198                    entry_mig_number: None,
1199                    child_groups: vec![AssembledGroup {
1200                        group_id: "SG5".to_string(),
1201                        repetitions: vec![AssembledGroupInstance {
1202                            segments: vec![make_segment(
1203                                "LOC",
1204                                vec![vec!["DE00012345678"]],
1205                                Some("0050"),
1206                            )],
1207                            child_groups: vec![],
1208                            entry_mig_number: None,
1209                            variant_mig_numbers: vec![],
1210                            skipped_segments: vec![],
1211                            skipped_positions: Vec::new(),
1212                        }],
1213                    }],
1214                    variant_mig_numbers: vec![],
1215                    skipped_segments: vec![],
1216                    skipped_positions: Vec::new(),
1217                }],
1218            }],
1219            post_group_start: 0,
1220            inter_group_segments: BTreeMap::new(),
1221        };
1222
1223        let result = build_validated_tree(&workflow, &tree);
1224        assert_eq!(result.groups.len(), 1);
1225        let sg4 = &result.groups[0];
1226        assert_eq!(sg4.children.len(), 1);
1227
1228        let sg5 = &sg4.children[0];
1229        assert_eq!(sg5.group_id, "SG5");
1230        assert_eq!(sg5.fields.len(), 1);
1231        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
1232        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
1233    }
1234
1235    #[test]
1236    fn test_qualifier_aware_segment_matching_same_tag_variants() {
1237        // MSCONS SG10 has four STS variants (9015=Z33, Z32, Z34, Z40) with
1238        // mig_numbers 00035..00038. The assembler matches same-tag MIG slots
1239        // positionally, so when input contains STS+Z32++Z92 and STS+Z40++Z75,
1240        // the segments end up tagged with mig=00035 and mig=00036 (the first
1241        // two MIG slots) instead of mig=00036 and mig=00038.
1242        //
1243        // `find_segment` must therefore prefer a segment whose qualifier value
1244        // matches the rule's single-code constraint over the assembler's
1245        // positional mig_number. Otherwise:
1246        //   * rule for Z33 (mig 00035) attaches to the Z32 segment and looks
1247        //     "present" with the wrong value, suppressing the genuine absence;
1248        //   * rule for Z40 (mig 00038) finds no segment and reports the Z40
1249        //     STS as "missing" even though it is present in the input.
1250        let mut workflow = empty_workflow();
1251
1252        // Z33 variant — not present in input, must resolve to None.
1253        let mut z33 = make_rule(
1254            "SG10/STS/C601/9015",
1255            "Statuskategorie Z33",
1256            "X",
1257            Some("00035"),
1258            Some(0),
1259            Some(0),
1260        );
1261        z33.codes = vec![super::super::validate::AhbCodeRule {
1262            value: "Z33".into(),
1263            description: String::new(),
1264            ahb_status: "X".into(),
1265        }];
1266        workflow.fields.push(z33);
1267
1268        // Z32 variant — present in input.
1269        let mut z32 = make_rule(
1270            "SG10/STS/C601/9015",
1271            "Statuskategorie Z32",
1272            "X",
1273            Some("00036"),
1274            Some(0),
1275            Some(0),
1276        );
1277        z32.codes = vec![super::super::validate::AhbCodeRule {
1278            value: "Z32".into(),
1279            description: String::new(),
1280            ahb_status: "X".into(),
1281        }];
1282        workflow.fields.push(z32);
1283
1284        // Z40 variant — present in input.
1285        let mut z40 = make_rule(
1286            "SG10/STS/C601/9015",
1287            "Statuskategorie Z40",
1288            "X",
1289            Some("00038"),
1290            Some(0),
1291            Some(0),
1292        );
1293        z40.codes = vec![super::super::validate::AhbCodeRule {
1294            value: "Z40".into(),
1295            description: String::new(),
1296            ahb_status: "X".into(),
1297        }];
1298        workflow.fields.push(z40);
1299
1300        // Assembler assigned mig=00035 to the Z32 segment and mig=00036 to the Z40
1301        // segment (positional), mimicking the real MSCONS 13025 pipeline output.
1302        let tree = AssembledTree {
1303            segments: vec![],
1304            groups: vec![AssembledGroup {
1305                group_id: "SG10".to_string(),
1306                repetitions: vec![AssembledGroupInstance {
1307                    segments: vec![
1308                        make_segment("STS", vec![vec!["Z32"], vec![], vec!["Z92"]], Some("00035")),
1309                        make_segment("STS", vec![vec!["Z40"], vec![], vec!["Z75"]], Some("00036")),
1310                    ],
1311                    child_groups: vec![],
1312                    entry_mig_number: None,
1313                    variant_mig_numbers: vec![
1314                        "00035".into(),
1315                        "00036".into(),
1316                        "00037".into(),
1317                        "00038".into(),
1318                    ],
1319                    skipped_segments: vec![],
1320                    skipped_positions: Vec::new(),
1321                }],
1322            }],
1323            post_group_start: 0,
1324            inter_group_segments: BTreeMap::new(),
1325        };
1326
1327        let result = build_validated_tree(&workflow, &tree);
1328        assert_eq!(result.groups.len(), 1);
1329        let sg10 = &result.groups[0];
1330        assert_eq!(
1331            sg10.fields.len(),
1332            3,
1333            "all three rules should pass the variant filter"
1334        );
1335
1336        let by_name = |name: &str| sg10.fields.iter().find(|f| f.rule.name == name).unwrap();
1337
1338        // Z33 is genuinely absent — no STS in the input has 9015=Z33.
1339        assert_eq!(
1340            by_name("Statuskategorie Z33").value,
1341            None,
1342            "Z33 has no matching STS in the input; must not silently pick up Z32's segment"
1343        );
1344
1345        // Z32 must resolve to the STS segment whose 9015 is Z32,
1346        // regardless of the assembler's mis-assigned mig_number.
1347        assert_eq!(
1348            by_name("Statuskategorie Z32").value,
1349            Some("Z32"),
1350            "rule for Z32 must attach to the STS segment with qualifier Z32"
1351        );
1352
1353        // Z40 must resolve to the STS segment whose 9015 is Z40.
1354        assert_eq!(
1355            by_name("Statuskategorie Z40").value,
1356            Some("Z40"),
1357            "rule for Z40 must attach to the STS segment with qualifier Z40"
1358        );
1359    }
1360}