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