Skip to main content

automapper_validation/validator/
tree.rs

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