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