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