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    }
371
372    if let Some(mig_num) = rule.mig_number.as_ref() {
373        // 1. Strict mig_number match.
374        if let Some((_, seg)) = segments
375            .iter()
376            .enumerate()
377            .find(|(i, s)| number_of(*i, s) == Some(mig_num.as_str()))
378        {
379            return Some(seg);
380        }
381
382        // 2. Tag fallback, excluding segments with a conflicting mig_number.
383        return segments
384            .iter()
385            .enumerate()
386            .find(|(i, s)| s.tag == tag && number_of(*i, s).map_or(true, |m| m == mig_num))
387            .map(|(_, s)| s);
388    }
389
390    // No mig_number on rule — pure tag match.
391    segments.iter().find(|s| s.tag == tag)
392}
393
394/// Extract the segment tag from a segment path.
395///
396/// `"SG4/DTM/C507/2380"` -> `"DTM"` (first non-SG component)
397/// `"BGM/1004"` -> `"BGM"`
398fn extract_segment_tag(segment_path: &str) -> Option<&str> {
399    segment_path.split('/').find(|part| !part.starts_with("SG"))
400}
401
402/// Extract a field value from an assembled segment using element/component indices.
403fn extract_value<'a>(segment: &'a AssembledSegment, rule: &AhbFieldRule) -> Option<&'a str> {
404    let elem_idx = rule.element_index.unwrap_or(0);
405    let comp_idx = rule.component_index.unwrap_or(0);
406
407    let element = segment.elements.get(elem_idx)?;
408    let component = element.get(comp_idx)?;
409
410    if component.is_empty() {
411        None
412    } else {
413        Some(component.as_str())
414    }
415}
416
417/// Resolve assembled groups against grouped AHB rules, recursively.
418///
419/// `depth` is the number of group prefixes to strip from each rule's
420/// `segment_path` before classifying it as direct or child.
421fn resolve_groups<'a>(
422    assembled_groups: &'a [AssembledGroup],
423    group_rules: &HashMap<String, Vec<&'a AhbFieldRule>>,
424    depth: usize,
425) -> Vec<AhbGroupNode<'a>> {
426    let mut result = Vec::new();
427
428    for assembled_group in assembled_groups {
429        let rules = group_rules.get(&assembled_group.group_id);
430
431        for (instance_index, instance) in assembled_group.repetitions.iter().enumerate() {
432            let mut node =
433                resolve_group_instance(&assembled_group.group_id, instance, rules, depth);
434            node.instance_index = instance_index;
435            result.push(node);
436        }
437    }
438
439    result
440}
441
442/// Strip `n` leading group prefixes from a segment path.
443///
444/// `strip_n_groups("SG4/SG5/LOC/3225", 2)` -> `"LOC/3225"`
445fn strip_n_groups(path: &str, n: usize) -> &str {
446    let mut rest = path;
447    for _ in 0..n {
448        match rest.find('/') {
449            Some(idx) => rest = &rest[idx + 1..],
450            None => return rest,
451        }
452    }
453    rest
454}
455
456/// Resolve a single group instance.
457///
458/// `depth` is how many group prefixes have been consumed so far (0 for top-level groups).
459///
460/// Rules are filtered to this instance by `mig_number`: a rule only applies if
461/// its `mig_number` matches a segment in this instance (or a segment in a child
462/// group instance). This prevents rules for SG8/SEQ+Z79 from generating false
463/// missing-field errors against an SG8/SEQ+Z01 rep.
464fn resolve_group_instance<'a>(
465    group_id: &'a str,
466    instance: &'a AssembledGroupInstance,
467    rules: Option<&Vec<&'a AhbFieldRule>>,
468    depth: usize,
469) -> AhbGroupNode<'a> {
470    // We need to strip (depth + 1) group prefixes to get below this group level.
471    let strip_count = depth + 1;
472
473    // Use the variant's full set of mig_numbers (from MIG definition) to
474    // determine which rules belong to this instance. This includes numbers
475    // for segments that may be absent — so missing-field detection still works.
476    // Falls back to collecting from present segments if variant_mig_numbers is empty.
477    let variant_numbers: std::collections::HashSet<&str> =
478        if !instance.variant_mig_numbers.is_empty() {
479            instance
480                .variant_mig_numbers
481                .iter()
482                .map(|s| s.as_str())
483                .collect()
484        } else {
485            collect_instance_mig_numbers(instance)
486        };
487
488    let mut direct_rules: Vec<&'a AhbFieldRule> = Vec::new();
489    let mut child_group_rules: HashMap<String, Vec<&'a AhbFieldRule>> = HashMap::new();
490    let mut ahb_status: Option<&'a str> = None;
491
492    // A group merged from several variants (QUOTES SG28: CCI+++E13 00029,
493    // CCI+++Z75 00042, …) carries one variant's MIG numbers and entry for
494    // every instance. The instance's own variant is the one whose entry code
495    // its entry segment carries; only that variant's rules apply.
496    let merged = rules.and_then(|rules| {
497        let own: Vec<&'a AhbFieldRule> = rules
498            .iter()
499            .copied()
500            .filter(|r| extract_child_group(strip_n_groups(&r.segment_path, strip_count)).is_none())
501            .collect();
502        merged_variant(&own, instance)
503    });
504
505    // The span of this instance's MIG numbers. A child group's rules belong
506    // to it when they lie inside: variants occupy contiguous MIG ranges, and
507    // the instance's own numbers name only one variant of a merged child
508    // (QUOTES SG28 keeps CCI+++Z75's), not all of them.
509    let span = variant_numbers
510        .iter()
511        .filter_map(|n| n.parse::<u32>().ok())
512        .fold(None, |acc: Option<(u32, u32)>, n| {
513            Some(acc.map_or((n, n), |(lo, hi)| (lo.min(n), hi.max(n))))
514        });
515
516    if let Some(rules) = rules {
517        for rule in rules {
518            // Strip all parent group prefixes plus this group to get the relative path.
519            let stripped = strip_n_groups(&rule.segment_path, strip_count);
520            let child = extract_child_group(stripped);
521
522            // Skip rules whose mig_number doesn't belong to this variant.
523            // Rules without mig_number pass through (tag-based fallback).
524            if let Some(ref rule_mig) = rule.mig_number {
525                let number = rule_mig.parse::<u32>().ok();
526                let belongs = match (&merged, child) {
527                    (Some((_, range)), _) => number.is_some_and(|m| range.contains(&m)),
528                    (None, Some(_)) => match (number, span) {
529                        (Some(m), Some((lo, hi))) => (lo..=hi).contains(&m),
530                        _ => variant_numbers.contains(rule_mig.as_str()),
531                    },
532                    (None, None) => variant_numbers.contains(rule_mig.as_str()),
533                };
534                if !belongs {
535                    continue;
536                }
537            }
538
539            if let Some(child_group_id) = child {
540                child_group_rules
541                    .entry(child_group_id.to_owned())
542                    .or_default()
543                    .push(rule);
544            } else {
545                direct_rules.push(rule);
546            }
547        }
548    }
549    // The group's status is its variant's: read from the rule of the instance's
550    // entry segment, else from any rule of this group proper with a MIG number
551    // of the instance — never a child group's rule (it carries the child's
552    // status) or a rule without a MIG number (it may be another variant's).
553    let own_status = |rule: &&'a AhbFieldRule| rule.parent_group_ahb_status.as_deref();
554    let entry = merged
555        .as_ref()
556        .map(|(entry, _)| *entry)
557        .or(instance.entry_mig_number.as_deref());
558    if let Some(status) = direct_rules
559        .iter()
560        .filter(|r| entry.is_some() && r.mig_number.as_deref() == entry)
561        .find_map(own_status)
562        .or_else(|| {
563            direct_rules
564                .iter()
565                .filter(|r| r.mig_number.is_some())
566                .find_map(own_status)
567        })
568    {
569        ahb_status = Some(status);
570    }
571
572    // Resolve direct fields against instance segments.
573    // The segments of a merged group's instance carry the first variant's MIG
574    // numbers, not their own: per tag, the n-th segment takes the variant's
575    // n-th slot (the assembler fills slots in MIG order too).
576    let numbers: Option<Vec<Option<String>>> = merged.as_ref().map(|_| {
577        let mut used: Vec<&str> = Vec::new();
578        instance
579            .segments
580            .iter()
581            .map(|seg| {
582                let of_tag = |r: &&&'a AhbFieldRule| {
583                    extract_segment_tag(&r.segment_path) == Some(seg.tag.as_str())
584                };
585                let mut slots: Vec<&str> = direct_rules
586                    .iter()
587                    .filter(of_tag)
588                    .filter_map(|r| r.mig_number.as_deref())
589                    .filter(|n| !used.contains(n))
590                    .collect();
591                slots.sort_by_key(|n| n.parse::<u32>().unwrap_or(u32::MAX));
592                slots.dedup();
593                // The slot whose codes the segment carries; else the first
594                // slot that lists no codes; else the next one in MIG order.
595                let coded = |slot: &str| {
596                    direct_rules
597                        .iter()
598                        .filter(of_tag)
599                        .filter(|r| r.mig_number.as_deref() == Some(slot) && !r.codes.is_empty())
600                        .collect::<Vec<_>>()
601                };
602                let carries = |slot: &&str| {
603                    coded(slot).iter().any(|r| {
604                        seg.elements
605                            .get(r.element_index.unwrap_or(0))
606                            .and_then(|e| e.get(r.component_index.unwrap_or(0)))
607                            .is_some_and(|v| r.codes.iter().any(|c| &c.value == v))
608                    })
609                };
610                let slot = slots
611                    .iter()
612                    .find(|s| carries(s))
613                    .or_else(|| slots.iter().find(|s| coded(s).is_empty()))
614                    .or_else(|| slots.first())
615                    .copied();
616                if let Some(slot) = slot {
617                    used.push(slot);
618                }
619                slot.map(str::to_string)
620            })
621            .collect()
622    });
623    let fields = resolve_fields(&direct_rules, &instance.segments, numbers.as_deref());
624
625    // Recurse into child groups one level deeper.
626    let children = resolve_groups(&instance.child_groups, &child_group_rules, strip_count);
627
628    AhbGroupNode {
629        group_id,
630        instance_index: 0,
631        ahb_status,
632        fields,
633        children,
634    }
635}
636
637/// Collect all mig_numbers present in a group instance, including child groups recursively.
638fn collect_instance_mig_numbers(
639    instance: &AssembledGroupInstance,
640) -> std::collections::HashSet<&str> {
641    let mut numbers = std::collections::HashSet::new();
642    for seg in &instance.segments {
643        if let Some(ref num) = seg.mig_number {
644            numbers.insert(num.as_str());
645        }
646    }
647    for child_group in &instance.child_groups {
648        for child_instance in &child_group.repetitions {
649            numbers.extend(collect_instance_mig_numbers(child_instance));
650        }
651    }
652    numbers
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::validator::validate::{AhbFieldRule, AhbWorkflow};
659    use mig_assembly::assembler::{
660        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
661    };
662    use std::collections::BTreeMap;
663
664    fn empty_workflow() -> AhbWorkflow {
665        AhbWorkflow {
666            pruefidentifikator: "11001".to_string(),
667            description: String::new(),
668            communication_direction: None,
669            fields: vec![],
670            ub_definitions: BTreeMap::new(),
671        }
672    }
673
674    fn empty_tree() -> AssembledTree {
675        AssembledTree {
676            segments: vec![],
677            groups: vec![],
678            post_group_start: 0,
679            inter_group_segments: BTreeMap::new(),
680        }
681    }
682
683    fn make_segment(
684        tag: &str,
685        elements: Vec<Vec<&str>>,
686        mig_number: Option<&str>,
687    ) -> AssembledSegment {
688        AssembledSegment {
689            tag: tag.to_string(),
690            elements: elements
691                .into_iter()
692                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
693                .collect(),
694            mig_number: mig_number.map(|s| s.to_string()),
695            segment_number: None,
696        }
697    }
698
699    fn make_rule(
700        segment_path: &str,
701        name: &str,
702        ahb_status: &str,
703        mig_number: Option<&str>,
704        element_index: Option<usize>,
705        component_index: Option<usize>,
706    ) -> AhbFieldRule {
707        AhbFieldRule {
708            segment_path: segment_path.to_string(),
709            name: name.to_string(),
710            ahb_status: ahb_status.to_string(),
711            codes: vec![],
712            parent_group_ahb_status: None,
713            segment_ahb_status: None,
714            element_index,
715            component_index,
716            mig_number: mig_number.map(|s| s.to_string()),
717        }
718    }
719
720    #[test]
721    fn test_empty_workflow_empty_tree() {
722        let workflow = empty_workflow();
723        let tree = empty_tree();
724        let result = build_validated_tree(&workflow, &tree);
725
726        assert_eq!(result.pruefidentifikator, "11001");
727        assert!(result.root_fields.is_empty());
728        assert!(result.groups.is_empty());
729    }
730
731    #[test]
732    fn test_root_field_matches_root_segment() {
733        let mut workflow = empty_workflow();
734        workflow.fields.push(make_rule(
735            "BGM/C002/1001",
736            "Nachrichtentyp",
737            "X",
738            Some("0001"),
739            Some(0),
740            Some(0),
741        ));
742
743        let tree = AssembledTree {
744            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
745            groups: vec![],
746            post_group_start: 1,
747            inter_group_segments: BTreeMap::new(),
748        };
749
750        let result = build_validated_tree(&workflow, &tree);
751
752        assert_eq!(result.root_fields.len(), 1);
753        let node = &result.root_fields[0];
754        assert_eq!(node.value, Some("E01"));
755        assert!(node.segment_elements.is_some());
756        assert_eq!(node.rule.name, "Nachrichtentyp");
757    }
758
759    #[test]
760    fn test_sg4_dtm_mig_number_matching() {
761        // Two DTM segments with different mig_numbers.
762        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
763        let mut workflow = empty_workflow();
764
765        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
766        workflow.fields.push(make_rule(
767            "SG4/DTM/C507/2380",
768            "Eingangsdatum",
769            "X",
770            Some("0082"),
771            Some(1), // date value is element 1
772            Some(0),
773        ));
774
775        // Rule for DTM+137.
776        workflow.fields.push(make_rule(
777            "SG4/DTM/C507/2380",
778            "Dokumentendatum",
779            "X",
780            Some("0083"),
781            Some(1),
782            Some(0),
783        ));
784
785        let tree = AssembledTree {
786            segments: vec![],
787            groups: vec![AssembledGroup {
788                group_id: "SG4".to_string(),
789                repetitions: vec![AssembledGroupInstance {
790                    segments: vec![
791                        make_segment("DTM", vec![vec!["92"], vec!["20260101"]], Some("0082")),
792                        make_segment("DTM", vec![vec!["137"], vec!["20260401"]], Some("0083")),
793                    ],
794                    child_groups: vec![],
795                    entry_mig_number: None,
796                    variant_mig_numbers: vec![],
797                    skipped_segments: vec![],
798                    skipped_positions: Vec::new(),
799                }],
800            }],
801            post_group_start: 0,
802            inter_group_segments: BTreeMap::new(),
803        };
804
805        let result = build_validated_tree(&workflow, &tree);
806
807        assert_eq!(result.groups.len(), 1);
808        let sg4 = &result.groups[0];
809        assert_eq!(sg4.group_id, "SG4");
810        assert_eq!(sg4.fields.len(), 2);
811
812        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
813        let eingangsdatum = &sg4.fields[0];
814        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
815        assert_eq!(eingangsdatum.value, Some("20260101"));
816
817        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
818        let dokumentendatum = &sg4.fields[1];
819        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
820        assert_eq!(dokumentendatum.value, Some("20260401"));
821    }
822
823    #[test]
824    fn test_rule_filtered_to_correct_variant() {
825        // A rule with mig_number "0099" is filtered out from an instance
826        // that doesn't contain any segment with that mig_number.
827        // This prevents false missing-field errors for wrong SG8 variants.
828        let mut workflow = empty_workflow();
829        workflow.fields.push(make_rule(
830            "SG4/RFF/C506/1154",
831            "Referenz",
832            "X",
833            Some("0099"),
834            Some(0),
835            Some(1),
836        ));
837
838        let tree = AssembledTree {
839            segments: vec![],
840            groups: vec![AssembledGroup {
841                group_id: "SG4".to_string(),
842                repetitions: vec![AssembledGroupInstance {
843                    segments: vec![], // No segments — wrong variant
844                    child_groups: vec![],
845                    entry_mig_number: None,
846                    variant_mig_numbers: vec![],
847                    skipped_segments: vec![],
848                    skipped_positions: Vec::new(),
849                }],
850            }],
851            post_group_start: 0,
852            inter_group_segments: BTreeMap::new(),
853        };
854
855        let result = build_validated_tree(&workflow, &tree);
856        assert_eq!(result.groups.len(), 1);
857        // Rule is filtered out — its mig_number doesn't match this instance
858        assert_eq!(result.groups[0].fields.len(), 0);
859    }
860
861    #[test]
862    fn test_missing_segment_within_correct_variant() {
863        // A rule with mig_number "0099" IS included when the instance's
864        // variant_mig_numbers lists it — even though the segment is absent.
865        // This enables missing-field detection within the correct variant.
866        let mut workflow = empty_workflow();
867        // Entry segment rule (present)
868        workflow.fields.push(make_rule(
869            "SG4/SEQ/1229",
870            "Qualifier",
871            "X",
872            Some("0098"),
873            Some(0),
874            Some(0),
875        ));
876        // Second segment rule (absent — should report missing)
877        workflow.fields.push(make_rule(
878            "SG4/RFF/C506/1154",
879            "Referenz",
880            "X",
881            Some("0099"),
882            Some(0),
883            Some(1),
884        ));
885
886        let tree = AssembledTree {
887            segments: vec![],
888            groups: vec![AssembledGroup {
889                group_id: "SG4".to_string(),
890                repetitions: vec![AssembledGroupInstance {
891                    segments: vec![
892                        // Only the entry segment — RFF is missing
893                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
894                    ],
895                    child_groups: vec![],
896                    entry_mig_number: Some("0098".to_string()),
897                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
898                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
899                    skipped_segments: vec![],
900                    skipped_positions: Vec::new(),
901                }],
902            }],
903            post_group_start: 0,
904            inter_group_segments: BTreeMap::new(),
905        };
906
907        let result = build_validated_tree(&workflow, &tree);
908        assert_eq!(result.groups.len(), 1);
909        // Both rules match because variant_mig_numbers includes both
910        assert_eq!(result.groups[0].fields.len(), 2);
911        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
912        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
913        // RFF is missing — value is None (condition eval will report it)
914        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
915        assert_eq!(result.groups[0].fields[1].value, None);
916    }
917
918    #[test]
919    fn test_missing_group_variant_populates_unmatched_rules() {
920        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
921        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
922        // so validate_tree can report them as missing using flat logic.
923        let mut workflow = empty_workflow();
924
925        // NAD+MS qualifier rule
926        workflow.fields.push(make_rule(
927            "SG2/NAD/3035",
928            "MP-ID Absender Qualifier",
929            "X",
930            Some("0010"),
931            Some(0),
932            Some(0),
933        ));
934        // NAD+MS ID rule
935        workflow.fields.push(make_rule(
936            "SG2/NAD/C082/3039",
937            "MP-ID Absender",
938            "X",
939            Some("0010"),
940            Some(1),
941            Some(0),
942        ));
943        // NAD+MR qualifier rule
944        workflow.fields.push(make_rule(
945            "SG2/NAD/3035",
946            "MP-ID Empfänger Qualifier",
947            "X",
948            Some("0011"),
949            Some(0),
950            Some(0),
951        ));
952        // NAD+MR ID rule
953        workflow.fields.push(make_rule(
954            "SG2/NAD/C082/3039",
955            "MP-ID Empfänger",
956            "X",
957            Some("0011"),
958            Some(1),
959            Some(0),
960        ));
961
962        // Only NAD+MR present in assembled tree.
963        let tree = AssembledTree {
964            segments: vec![],
965            groups: vec![AssembledGroup {
966                group_id: "SG2".to_string(),
967                repetitions: vec![AssembledGroupInstance {
968                    segments: vec![make_segment(
969                        "NAD",
970                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
971                        Some("0011"),
972                    )],
973                    child_groups: vec![],
974                    entry_mig_number: Some("0011".to_string()),
975                    variant_mig_numbers: vec!["0011".to_string()],
976                    skipped_segments: vec![],
977                    skipped_positions: Vec::new(),
978                }],
979            }],
980            post_group_start: 0,
981            inter_group_segments: BTreeMap::new(),
982        };
983
984        let result = build_validated_tree(&workflow, &tree);
985
986        // Tree should have 1 SG2 group node (NAD+MR).
987        assert_eq!(result.groups.len(), 1);
988        assert_eq!(result.groups[0].fields.len(), 2);
989        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
990
991        // NAD+MS rules should be in unmatched_rules.
992        assert_eq!(
993            result.unmatched_rules.len(),
994            2,
995            "Expected 2 unmatched rules (NAD+MS), got {}",
996            result.unmatched_rules.len()
997        );
998        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
999        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
1000    }
1001
1002    #[test]
1003    fn test_entirely_absent_group_populates_unmatched_rules() {
1004        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
1005        let mut workflow = empty_workflow();
1006        workflow.fields.push(make_rule(
1007            "SG2/NAD/3035",
1008            "MP-ID Absender Qualifier",
1009            "X",
1010            Some("0010"),
1011            Some(0),
1012            Some(0),
1013        ));
1014
1015        let tree = empty_tree(); // No groups at all.
1016
1017        let result = build_validated_tree(&workflow, &tree);
1018
1019        // No tree nodes.
1020        assert!(result.groups.is_empty());
1021
1022        // The rule should be in unmatched_rules.
1023        assert_eq!(
1024            result.unmatched_rules.len(),
1025            1,
1026            "Expected 1 unmatched rule, got {}",
1027            result.unmatched_rules.len()
1028        );
1029        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
1030    }
1031
1032    #[test]
1033    fn test_fallback_to_tag_when_no_mig_number() {
1034        let mut workflow = empty_workflow();
1035        workflow.fields.push(make_rule(
1036            "BGM/C002/1001",
1037            "Nachrichtentyp",
1038            "X",
1039            None, // No mig_number — should fall back to tag.
1040            Some(0),
1041            Some(0),
1042        ));
1043
1044        let tree = AssembledTree {
1045            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
1046            groups: vec![],
1047            post_group_start: 1,
1048            inter_group_segments: BTreeMap::new(),
1049        };
1050
1051        let result = build_validated_tree(&workflow, &tree);
1052        assert_eq!(result.root_fields.len(), 1);
1053        assert_eq!(result.root_fields[0].value, Some("E01"));
1054    }
1055
1056    #[test]
1057    fn test_nested_child_groups() {
1058        let mut workflow = empty_workflow();
1059        // A rule in SG4/SG5.
1060        workflow.fields.push(make_rule(
1061            "SG4/SG5/LOC/C517/3225",
1062            "Marktlokations-ID",
1063            "X",
1064            Some("0050"),
1065            Some(0),
1066            Some(0),
1067        ));
1068
1069        let tree = AssembledTree {
1070            segments: vec![],
1071            groups: vec![AssembledGroup {
1072                group_id: "SG4".to_string(),
1073                repetitions: vec![AssembledGroupInstance {
1074                    segments: vec![],
1075                    entry_mig_number: None,
1076                    child_groups: vec![AssembledGroup {
1077                        group_id: "SG5".to_string(),
1078                        repetitions: vec![AssembledGroupInstance {
1079                            segments: vec![make_segment(
1080                                "LOC",
1081                                vec![vec!["DE00012345678"]],
1082                                Some("0050"),
1083                            )],
1084                            child_groups: vec![],
1085                            entry_mig_number: None,
1086                            variant_mig_numbers: vec![],
1087                            skipped_segments: vec![],
1088                            skipped_positions: Vec::new(),
1089                        }],
1090                    }],
1091                    variant_mig_numbers: vec![],
1092                    skipped_segments: vec![],
1093                    skipped_positions: Vec::new(),
1094                }],
1095            }],
1096            post_group_start: 0,
1097            inter_group_segments: BTreeMap::new(),
1098        };
1099
1100        let result = build_validated_tree(&workflow, &tree);
1101        assert_eq!(result.groups.len(), 1);
1102        let sg4 = &result.groups[0];
1103        assert_eq!(sg4.children.len(), 1);
1104
1105        let sg5 = &sg4.children[0];
1106        assert_eq!(sg5.group_id, "SG5");
1107        assert_eq!(sg5.fields.len(), 1);
1108        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
1109        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
1110    }
1111
1112    #[test]
1113    fn test_qualifier_aware_segment_matching_same_tag_variants() {
1114        // MSCONS SG10 has four STS variants (9015=Z33, Z32, Z34, Z40) with
1115        // mig_numbers 00035..00038. The assembler matches same-tag MIG slots
1116        // positionally, so when input contains STS+Z32++Z92 and STS+Z40++Z75,
1117        // the segments end up tagged with mig=00035 and mig=00036 (the first
1118        // two MIG slots) instead of mig=00036 and mig=00038.
1119        //
1120        // `find_segment` must therefore prefer a segment whose qualifier value
1121        // matches the rule's single-code constraint over the assembler's
1122        // positional mig_number. Otherwise:
1123        //   * rule for Z33 (mig 00035) attaches to the Z32 segment and looks
1124        //     "present" with the wrong value, suppressing the genuine absence;
1125        //   * rule for Z40 (mig 00038) finds no segment and reports the Z40
1126        //     STS as "missing" even though it is present in the input.
1127        let mut workflow = empty_workflow();
1128
1129        // Z33 variant — not present in input, must resolve to None.
1130        let mut z33 = make_rule(
1131            "SG10/STS/C601/9015",
1132            "Statuskategorie Z33",
1133            "X",
1134            Some("00035"),
1135            Some(0),
1136            Some(0),
1137        );
1138        z33.codes = vec![super::super::validate::AhbCodeRule {
1139            value: "Z33".into(),
1140            description: String::new(),
1141            ahb_status: "X".into(),
1142        }];
1143        workflow.fields.push(z33);
1144
1145        // Z32 variant — present in input.
1146        let mut z32 = make_rule(
1147            "SG10/STS/C601/9015",
1148            "Statuskategorie Z32",
1149            "X",
1150            Some("00036"),
1151            Some(0),
1152            Some(0),
1153        );
1154        z32.codes = vec![super::super::validate::AhbCodeRule {
1155            value: "Z32".into(),
1156            description: String::new(),
1157            ahb_status: "X".into(),
1158        }];
1159        workflow.fields.push(z32);
1160
1161        // Z40 variant — present in input.
1162        let mut z40 = make_rule(
1163            "SG10/STS/C601/9015",
1164            "Statuskategorie Z40",
1165            "X",
1166            Some("00038"),
1167            Some(0),
1168            Some(0),
1169        );
1170        z40.codes = vec![super::super::validate::AhbCodeRule {
1171            value: "Z40".into(),
1172            description: String::new(),
1173            ahb_status: "X".into(),
1174        }];
1175        workflow.fields.push(z40);
1176
1177        // Assembler assigned mig=00035 to the Z32 segment and mig=00036 to the Z40
1178        // segment (positional), mimicking the real MSCONS 13025 pipeline output.
1179        let tree = AssembledTree {
1180            segments: vec![],
1181            groups: vec![AssembledGroup {
1182                group_id: "SG10".to_string(),
1183                repetitions: vec![AssembledGroupInstance {
1184                    segments: vec![
1185                        make_segment("STS", vec![vec!["Z32"], vec![], vec!["Z92"]], Some("00035")),
1186                        make_segment("STS", vec![vec!["Z40"], vec![], vec!["Z75"]], Some("00036")),
1187                    ],
1188                    child_groups: vec![],
1189                    entry_mig_number: None,
1190                    variant_mig_numbers: vec![
1191                        "00035".into(),
1192                        "00036".into(),
1193                        "00037".into(),
1194                        "00038".into(),
1195                    ],
1196                    skipped_segments: vec![],
1197                    skipped_positions: Vec::new(),
1198                }],
1199            }],
1200            post_group_start: 0,
1201            inter_group_segments: BTreeMap::new(),
1202        };
1203
1204        let result = build_validated_tree(&workflow, &tree);
1205        assert_eq!(result.groups.len(), 1);
1206        let sg10 = &result.groups[0];
1207        assert_eq!(
1208            sg10.fields.len(),
1209            3,
1210            "all three rules should pass the variant filter"
1211        );
1212
1213        let by_name = |name: &str| sg10.fields.iter().find(|f| f.rule.name == name).unwrap();
1214
1215        // Z33 is genuinely absent — no STS in the input has 9015=Z33.
1216        assert_eq!(
1217            by_name("Statuskategorie Z33").value,
1218            None,
1219            "Z33 has no matching STS in the input; must not silently pick up Z32's segment"
1220        );
1221
1222        // Z32 must resolve to the STS segment whose 9015 is Z32,
1223        // regardless of the assembler's mis-assigned mig_number.
1224        assert_eq!(
1225            by_name("Statuskategorie Z32").value,
1226            Some("Z32"),
1227            "rule for Z32 must attach to the STS segment with qualifier Z32"
1228        );
1229
1230        // Z40 must resolve to the STS segment whose 9015 is Z40.
1231        assert_eq!(
1232            by_name("Statuskategorie Z40").value,
1233            Some("Z40"),
1234            "rule for Z40 must attach to the STS segment with qualifier Z40"
1235        );
1236    }
1237}