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            segment_ahb_status: None,
490            element_index,
491            component_index,
492            mig_number: mig_number.map(|s| s.to_string()),
493        }
494    }
495
496    #[test]
497    fn test_empty_workflow_empty_tree() {
498        let workflow = empty_workflow();
499        let tree = empty_tree();
500        let result = build_validated_tree(&workflow, &tree);
501
502        assert_eq!(result.pruefidentifikator, "11001");
503        assert!(result.root_fields.is_empty());
504        assert!(result.groups.is_empty());
505    }
506
507    #[test]
508    fn test_root_field_matches_root_segment() {
509        let mut workflow = empty_workflow();
510        workflow.fields.push(make_rule(
511            "BGM/C002/1001",
512            "Nachrichtentyp",
513            "X",
514            Some("0001"),
515            Some(0),
516            Some(0),
517        ));
518
519        let tree = AssembledTree {
520            segments: vec![make_segment("BGM", vec![vec!["E01"]], Some("0001"))],
521            groups: vec![],
522            post_group_start: 1,
523            inter_group_segments: BTreeMap::new(),
524        };
525
526        let result = build_validated_tree(&workflow, &tree);
527
528        assert_eq!(result.root_fields.len(), 1);
529        let node = &result.root_fields[0];
530        assert_eq!(node.value, Some("E01"));
531        assert!(node.segment_elements.is_some());
532        assert_eq!(node.rule.name, "Nachrichtentyp");
533    }
534
535    #[test]
536    fn test_sg4_dtm_mig_number_matching() {
537        // Two DTM segments with different mig_numbers.
538        // AHB rule for DTM+92 (mig_number "0082") should match the correct one.
539        let mut workflow = empty_workflow();
540
541        // Rule for DTM+92: element 0 = qualifier "92", element 1 = the date value.
542        workflow.fields.push(make_rule(
543            "SG4/DTM/C507/2380",
544            "Eingangsdatum",
545            "X",
546            Some("0082"),
547            Some(1), // date value is element 1
548            Some(0),
549        ));
550
551        // Rule for DTM+137.
552        workflow.fields.push(make_rule(
553            "SG4/DTM/C507/2380",
554            "Dokumentendatum",
555            "X",
556            Some("0083"),
557            Some(1),
558            Some(0),
559        ));
560
561        let tree = AssembledTree {
562            segments: vec![],
563            groups: vec![AssembledGroup {
564                group_id: "SG4".to_string(),
565                repetitions: vec![AssembledGroupInstance {
566                    segments: vec![
567                        make_segment("DTM", vec![vec!["92"], vec!["20260101"]], Some("0082")),
568                        make_segment("DTM", vec![vec!["137"], vec!["20260401"]], Some("0083")),
569                    ],
570                    child_groups: vec![],
571                    entry_mig_number: None,
572                    variant_mig_numbers: vec![],
573                    skipped_segments: vec![],
574                    skipped_positions: Vec::new(),
575                }],
576            }],
577            post_group_start: 0,
578            inter_group_segments: BTreeMap::new(),
579        };
580
581        let result = build_validated_tree(&workflow, &tree);
582
583        assert_eq!(result.groups.len(), 1);
584        let sg4 = &result.groups[0];
585        assert_eq!(sg4.group_id, "SG4");
586        assert_eq!(sg4.fields.len(), 2);
587
588        // Eingangsdatum (mig_number 0082) should get DTM+92's date.
589        let eingangsdatum = &sg4.fields[0];
590        assert_eq!(eingangsdatum.rule.name, "Eingangsdatum");
591        assert_eq!(eingangsdatum.value, Some("20260101"));
592
593        // Dokumentendatum (mig_number 0083) should get DTM+137's date.
594        let dokumentendatum = &sg4.fields[1];
595        assert_eq!(dokumentendatum.rule.name, "Dokumentendatum");
596        assert_eq!(dokumentendatum.value, Some("20260401"));
597    }
598
599    #[test]
600    fn test_rule_filtered_to_correct_variant() {
601        // A rule with mig_number "0099" is filtered out from an instance
602        // that doesn't contain any segment with that mig_number.
603        // This prevents false missing-field errors for wrong SG8 variants.
604        let mut workflow = empty_workflow();
605        workflow.fields.push(make_rule(
606            "SG4/RFF/C506/1154",
607            "Referenz",
608            "X",
609            Some("0099"),
610            Some(0),
611            Some(1),
612        ));
613
614        let tree = AssembledTree {
615            segments: vec![],
616            groups: vec![AssembledGroup {
617                group_id: "SG4".to_string(),
618                repetitions: vec![AssembledGroupInstance {
619                    segments: vec![], // No segments — wrong variant
620                    child_groups: vec![],
621                    entry_mig_number: None,
622                    variant_mig_numbers: vec![],
623                    skipped_segments: vec![],
624                    skipped_positions: Vec::new(),
625                }],
626            }],
627            post_group_start: 0,
628            inter_group_segments: BTreeMap::new(),
629        };
630
631        let result = build_validated_tree(&workflow, &tree);
632        assert_eq!(result.groups.len(), 1);
633        // Rule is filtered out — its mig_number doesn't match this instance
634        assert_eq!(result.groups[0].fields.len(), 0);
635    }
636
637    #[test]
638    fn test_missing_segment_within_correct_variant() {
639        // A rule with mig_number "0099" IS included when the instance's
640        // variant_mig_numbers lists it — even though the segment is absent.
641        // This enables missing-field detection within the correct variant.
642        let mut workflow = empty_workflow();
643        // Entry segment rule (present)
644        workflow.fields.push(make_rule(
645            "SG4/SEQ/1229",
646            "Qualifier",
647            "X",
648            Some("0098"),
649            Some(0),
650            Some(0),
651        ));
652        // Second segment rule (absent — should report missing)
653        workflow.fields.push(make_rule(
654            "SG4/RFF/C506/1154",
655            "Referenz",
656            "X",
657            Some("0099"),
658            Some(0),
659            Some(1),
660        ));
661
662        let tree = AssembledTree {
663            segments: vec![],
664            groups: vec![AssembledGroup {
665                group_id: "SG4".to_string(),
666                repetitions: vec![AssembledGroupInstance {
667                    segments: vec![
668                        // Only the entry segment — RFF is missing
669                        make_segment("SEQ", vec![vec!["Z01"]], Some("0098")),
670                    ],
671                    child_groups: vec![],
672                    entry_mig_number: Some("0098".to_string()),
673                    // variant_mig_numbers includes both "0098" (SEQ) and "0099" (RFF)
674                    variant_mig_numbers: vec!["0098".to_string(), "0099".to_string()],
675                    skipped_segments: vec![],
676                    skipped_positions: Vec::new(),
677                }],
678            }],
679            post_group_start: 0,
680            inter_group_segments: BTreeMap::new(),
681        };
682
683        let result = build_validated_tree(&workflow, &tree);
684        assert_eq!(result.groups.len(), 1);
685        // Both rules match because variant_mig_numbers includes both
686        assert_eq!(result.groups[0].fields.len(), 2);
687        assert_eq!(result.groups[0].fields[0].rule.name, "Qualifier");
688        assert_eq!(result.groups[0].fields[0].value, Some("Z01"));
689        // RFF is missing — value is None (condition eval will report it)
690        assert_eq!(result.groups[0].fields[1].rule.name, "Referenz");
691        assert_eq!(result.groups[0].fields[1].value, None);
692    }
693
694    #[test]
695    fn test_missing_group_variant_populates_unmatched_rules() {
696        // AHB requires two SG2 variants: NAD+MS (mig "0010") and NAD+MR (mig "0011").
697        // EDIFACT only has NAD+MR. Rules for NAD+MS must appear in unmatched_rules
698        // so validate_tree can report them as missing using flat logic.
699        let mut workflow = empty_workflow();
700
701        // NAD+MS qualifier rule
702        workflow.fields.push(make_rule(
703            "SG2/NAD/3035",
704            "MP-ID Absender Qualifier",
705            "X",
706            Some("0010"),
707            Some(0),
708            Some(0),
709        ));
710        // NAD+MS ID rule
711        workflow.fields.push(make_rule(
712            "SG2/NAD/C082/3039",
713            "MP-ID Absender",
714            "X",
715            Some("0010"),
716            Some(1),
717            Some(0),
718        ));
719        // NAD+MR qualifier rule
720        workflow.fields.push(make_rule(
721            "SG2/NAD/3035",
722            "MP-ID Empfänger Qualifier",
723            "X",
724            Some("0011"),
725            Some(0),
726            Some(0),
727        ));
728        // NAD+MR ID rule
729        workflow.fields.push(make_rule(
730            "SG2/NAD/C082/3039",
731            "MP-ID Empfänger",
732            "X",
733            Some("0011"),
734            Some(1),
735            Some(0),
736        ));
737
738        // Only NAD+MR present in assembled tree.
739        let tree = AssembledTree {
740            segments: vec![],
741            groups: vec![AssembledGroup {
742                group_id: "SG2".to_string(),
743                repetitions: vec![AssembledGroupInstance {
744                    segments: vec![make_segment(
745                        "NAD",
746                        vec![vec!["MR"], vec!["9900269000000", "", "293"]],
747                        Some("0011"),
748                    )],
749                    child_groups: vec![],
750                    entry_mig_number: Some("0011".to_string()),
751                    variant_mig_numbers: vec!["0011".to_string()],
752                    skipped_segments: vec![],
753                    skipped_positions: Vec::new(),
754                }],
755            }],
756            post_group_start: 0,
757            inter_group_segments: BTreeMap::new(),
758        };
759
760        let result = build_validated_tree(&workflow, &tree);
761
762        // Tree should have 1 SG2 group node (NAD+MR).
763        assert_eq!(result.groups.len(), 1);
764        assert_eq!(result.groups[0].fields.len(), 2);
765        assert_eq!(result.groups[0].fields[0].value, Some("MR"));
766
767        // NAD+MS rules should be in unmatched_rules.
768        assert_eq!(
769            result.unmatched_rules.len(),
770            2,
771            "Expected 2 unmatched rules (NAD+MS), got {}",
772            result.unmatched_rules.len()
773        );
774        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
775        assert_eq!(result.unmatched_rules[1].name, "MP-ID Absender");
776    }
777
778    #[test]
779    fn test_entirely_absent_group_populates_unmatched_rules() {
780        // AHB requires SG2 with NAD+MS (mig "0010"), but no SG2 exists at all.
781        let mut workflow = empty_workflow();
782        workflow.fields.push(make_rule(
783            "SG2/NAD/3035",
784            "MP-ID Absender Qualifier",
785            "X",
786            Some("0010"),
787            Some(0),
788            Some(0),
789        ));
790
791        let tree = empty_tree(); // No groups at all.
792
793        let result = build_validated_tree(&workflow, &tree);
794
795        // No tree nodes.
796        assert!(result.groups.is_empty());
797
798        // The rule should be in unmatched_rules.
799        assert_eq!(
800            result.unmatched_rules.len(),
801            1,
802            "Expected 1 unmatched rule, got {}",
803            result.unmatched_rules.len()
804        );
805        assert_eq!(result.unmatched_rules[0].name, "MP-ID Absender Qualifier");
806    }
807
808    #[test]
809    fn test_fallback_to_tag_when_no_mig_number() {
810        let mut workflow = empty_workflow();
811        workflow.fields.push(make_rule(
812            "BGM/C002/1001",
813            "Nachrichtentyp",
814            "X",
815            None, // No mig_number — should fall back to tag.
816            Some(0),
817            Some(0),
818        ));
819
820        let tree = AssembledTree {
821            segments: vec![make_segment("BGM", vec![vec!["E01"]], None)],
822            groups: vec![],
823            post_group_start: 1,
824            inter_group_segments: BTreeMap::new(),
825        };
826
827        let result = build_validated_tree(&workflow, &tree);
828        assert_eq!(result.root_fields.len(), 1);
829        assert_eq!(result.root_fields[0].value, Some("E01"));
830    }
831
832    #[test]
833    fn test_nested_child_groups() {
834        let mut workflow = empty_workflow();
835        // A rule in SG4/SG5.
836        workflow.fields.push(make_rule(
837            "SG4/SG5/LOC/C517/3225",
838            "Marktlokations-ID",
839            "X",
840            Some("0050"),
841            Some(0),
842            Some(0),
843        ));
844
845        let tree = AssembledTree {
846            segments: vec![],
847            groups: vec![AssembledGroup {
848                group_id: "SG4".to_string(),
849                repetitions: vec![AssembledGroupInstance {
850                    segments: vec![],
851                    entry_mig_number: None,
852                    child_groups: vec![AssembledGroup {
853                        group_id: "SG5".to_string(),
854                        repetitions: vec![AssembledGroupInstance {
855                            segments: vec![make_segment(
856                                "LOC",
857                                vec![vec!["DE00012345678"]],
858                                Some("0050"),
859                            )],
860                            child_groups: vec![],
861                            entry_mig_number: None,
862                            variant_mig_numbers: vec![],
863                            skipped_segments: vec![],
864                            skipped_positions: Vec::new(),
865                        }],
866                    }],
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        let sg4 = &result.groups[0];
879        assert_eq!(sg4.children.len(), 1);
880
881        let sg5 = &sg4.children[0];
882        assert_eq!(sg5.group_id, "SG5");
883        assert_eq!(sg5.fields.len(), 1);
884        assert_eq!(sg5.fields[0].value, Some("DE00012345678"));
885        assert_eq!(sg5.fields[0].rule.name, "Marktlokations-ID");
886    }
887
888    #[test]
889    fn test_qualifier_aware_segment_matching_same_tag_variants() {
890        // MSCONS SG10 has four STS variants (9015=Z33, Z32, Z34, Z40) with
891        // mig_numbers 00035..00038. The assembler matches same-tag MIG slots
892        // positionally, so when input contains STS+Z32++Z92 and STS+Z40++Z75,
893        // the segments end up tagged with mig=00035 and mig=00036 (the first
894        // two MIG slots) instead of mig=00036 and mig=00038.
895        //
896        // `find_segment` must therefore prefer a segment whose qualifier value
897        // matches the rule's single-code constraint over the assembler's
898        // positional mig_number. Otherwise:
899        //   * rule for Z33 (mig 00035) attaches to the Z32 segment and looks
900        //     "present" with the wrong value, suppressing the genuine absence;
901        //   * rule for Z40 (mig 00038) finds no segment and reports the Z40
902        //     STS as "missing" even though it is present in the input.
903        let mut workflow = empty_workflow();
904
905        // Z33 variant — not present in input, must resolve to None.
906        let mut z33 = make_rule(
907            "SG10/STS/C601/9015",
908            "Statuskategorie Z33",
909            "X",
910            Some("00035"),
911            Some(0),
912            Some(0),
913        );
914        z33.codes = vec![super::super::validate::AhbCodeRule {
915            value: "Z33".into(),
916            description: String::new(),
917            ahb_status: "X".into(),
918        }];
919        workflow.fields.push(z33);
920
921        // Z32 variant — present in input.
922        let mut z32 = make_rule(
923            "SG10/STS/C601/9015",
924            "Statuskategorie Z32",
925            "X",
926            Some("00036"),
927            Some(0),
928            Some(0),
929        );
930        z32.codes = vec![super::super::validate::AhbCodeRule {
931            value: "Z32".into(),
932            description: String::new(),
933            ahb_status: "X".into(),
934        }];
935        workflow.fields.push(z32);
936
937        // Z40 variant — present in input.
938        let mut z40 = make_rule(
939            "SG10/STS/C601/9015",
940            "Statuskategorie Z40",
941            "X",
942            Some("00038"),
943            Some(0),
944            Some(0),
945        );
946        z40.codes = vec![super::super::validate::AhbCodeRule {
947            value: "Z40".into(),
948            description: String::new(),
949            ahb_status: "X".into(),
950        }];
951        workflow.fields.push(z40);
952
953        // Assembler assigned mig=00035 to the Z32 segment and mig=00036 to the Z40
954        // segment (positional), mimicking the real MSCONS 13025 pipeline output.
955        let tree = AssembledTree {
956            segments: vec![],
957            groups: vec![AssembledGroup {
958                group_id: "SG10".to_string(),
959                repetitions: vec![AssembledGroupInstance {
960                    segments: vec![
961                        make_segment("STS", vec![vec!["Z32"], vec![], vec!["Z92"]], Some("00035")),
962                        make_segment("STS", vec![vec!["Z40"], vec![], vec!["Z75"]], Some("00036")),
963                    ],
964                    child_groups: vec![],
965                    entry_mig_number: None,
966                    variant_mig_numbers: vec![
967                        "00035".into(),
968                        "00036".into(),
969                        "00037".into(),
970                        "00038".into(),
971                    ],
972                    skipped_segments: vec![],
973                    skipped_positions: Vec::new(),
974                }],
975            }],
976            post_group_start: 0,
977            inter_group_segments: BTreeMap::new(),
978        };
979
980        let result = build_validated_tree(&workflow, &tree);
981        assert_eq!(result.groups.len(), 1);
982        let sg10 = &result.groups[0];
983        assert_eq!(
984            sg10.fields.len(),
985            3,
986            "all three rules should pass the variant filter"
987        );
988
989        let by_name = |name: &str| sg10.fields.iter().find(|f| f.rule.name == name).unwrap();
990
991        // Z33 is genuinely absent — no STS in the input has 9015=Z33.
992        assert_eq!(
993            by_name("Statuskategorie Z33").value,
994            None,
995            "Z33 has no matching STS in the input; must not silently pick up Z32's segment"
996        );
997
998        // Z32 must resolve to the STS segment whose 9015 is Z32,
999        // regardless of the assembler's mis-assigned mig_number.
1000        assert_eq!(
1001            by_name("Statuskategorie Z32").value,
1002            Some("Z32"),
1003            "rule for Z32 must attach to the STS segment with qualifier Z32"
1004        );
1005
1006        // Z40 must resolve to the STS segment whose 9015 is Z40.
1007        assert_eq!(
1008            by_name("Statuskategorie Z40").value,
1009            Some("Z40"),
1010            "rule for Z40 must attach to the STS segment with qualifier Z40"
1011        );
1012    }
1013}