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