Skip to main content

mig_assembly/
disassembler.rs

1//! Tree disassembler — converts AssembledTree back to ordered segments.
2//!
3//! Walks the MIG schema tree in order. For each MIG node that has
4//! corresponding data in the assembled tree, emits segments in MIG order.
5
6use crate::assembler::{AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree};
7use crate::error::AssemblyError;
8use mig_types::schema::mig::{MigSchema, MigSegmentGroup};
9
10/// Output segment from disassembly (owned data, ready for rendering).
11#[derive(Debug, Clone)]
12pub struct DisassembledSegment {
13    pub tag: String,
14    pub elements: Vec<Vec<String>>,
15}
16
17/// A group instance that carries content but not the segment that opens the
18/// group in the MIG (e.g. an SG10 with a `CAV` but no `CCI`).
19///
20/// Rendering such an instance yields EDIFACT no receiver can assemble: the
21/// orphaned segments are rejected or skipped, and their data is lost. See
22/// [`Disassembler::disassemble_checked`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct MissingEntrySegment {
25    /// MIG group path from the message root, e.g. `"SG4.SG8.SG10"`.
26    pub group_path: String,
27    /// The same path in the TOML `source_path` convention, with the variant
28    /// qualifier where the MIG defines one, e.g. `"sg4.sg8_z03.sg10"`.
29    pub source_path: String,
30    /// Tag of the MIG entry segment the instance lacks, e.g. `"CCI"`.
31    pub entry_segment: String,
32    /// Tags of the segments the instance does carry, in instance order.
33    pub present_segments: Vec<String>,
34}
35
36/// MIG-guided disassembler — walks the MIG tree to emit segments in correct order.
37pub struct Disassembler<'a> {
38    mig: &'a MigSchema,
39}
40
41/// Mutable state threaded through one disassembly walk.
42struct Emit {
43    output: Vec<DisassembledSegment>,
44    /// `(group_path, source_path)` of each enclosing group instance.
45    path: Vec<(String, String)>,
46    missing: Vec<MissingEntrySegment>,
47}
48
49impl Emit {
50    fn push(&mut self, seg: &AssembledSegment) {
51        self.output.push(assembled_to_disassembled(seg));
52    }
53}
54
55impl<'a> Disassembler<'a> {
56    pub fn new(mig: &'a MigSchema) -> Self {
57        Self { mig }
58    }
59
60    /// Disassemble a tree into ordered segments following MIG sequence.
61    ///
62    /// Emits segments in correct EDIFACT order:
63    /// 1. Pre-group top-level segments (e.g., UNB, UNH, BGM, DTM)
64    /// 2. Groups (recursively, in MIG order)
65    /// 3. Post-group top-level segments (e.g., UNT, UNZ)
66    ///
67    /// Uses MIG-guided ordering: walks the MIG schema tree and looks up
68    /// matching data in the assembled tree. This handles both assembler output
69    /// (already in MIG order) and reverse-mapped trees (may be in arbitrary order).
70    ///
71    /// This is lenient: a group instance lacking its entry segment is emitted
72    /// as-is. Use [`disassemble_checked`](Self::disassemble_checked) for output
73    /// built from BO4E that is meant to be sent.
74    pub fn disassemble(&self, tree: &AssembledTree) -> Vec<DisassembledSegment> {
75        self.walk(tree).output
76    }
77
78    /// Like [`disassemble`](Self::disassemble), but refuses a group instance
79    /// that has content without its MIG entry segment.
80    ///
81    /// The entry segment opens a group repetition (CCI for SG10, SEQ for SG8,
82    /// NAD for SG12, …). Without it a receiver cannot assemble the group's
83    /// other segments — our own assembler skips them, so their data is lost on
84    /// parse. The error names the first offending group; all of them are
85    /// available through [`missing_entry_segments`](Self::missing_entry_segments).
86    pub fn disassemble_checked(
87        &self,
88        tree: &AssembledTree,
89    ) -> Result<Vec<DisassembledSegment>, AssemblyError> {
90        let emit = self.walk(tree);
91        match emit.missing.into_iter().next() {
92            None => Ok(emit.output),
93            Some(m) => Err(AssemblyError::MissingGroupEntrySegment {
94                group_path: m.group_path,
95                source_path: m.source_path,
96                entry_segment: m.entry_segment,
97                present_segments: m.present_segments,
98            }),
99        }
100    }
101
102    /// Every group instance in `tree` that carries content but lacks its MIG
103    /// entry segment, in emission order.
104    pub fn missing_entry_segments(&self, tree: &AssembledTree) -> Vec<MissingEntrySegment> {
105        self.walk(tree).missing
106    }
107
108    fn walk(&self, tree: &AssembledTree) -> Emit {
109        let mut emit = Emit {
110            output: Vec::new(),
111            path: Vec::new(),
112            missing: Vec::new(),
113        };
114
115        // 1. Emit pre-group segments in MIG order
116        let pre_group = &tree.segments[..tree.post_group_start];
117        let mut consumed = vec![false; pre_group.len()];
118        for mig_seg in &self.mig.segments {
119            if let Some(idx) = pre_group
120                .iter()
121                .enumerate()
122                .position(|(i, s)| !consumed[i] && s.tag == mig_seg.id)
123            {
124                emit.push(&pre_group[idx]);
125                consumed[idx] = true;
126            }
127        }
128
129        // 2. Emit groups in MIG order (lookup by group ID with consumption tracking).
130        //    Between groups, emit any inter-group root segments (e.g., UNS in MSCONS).
131        //    For variant groups (consecutive same-ID with variant_code), collect all
132        //    variant definitions and match each rep to its correct variant.
133        let mut consumed_groups = vec![false; tree.groups.len()];
134        let mut mig_group_idx = 0;
135        while mig_group_idx < self.mig.segment_groups.len() {
136            let mig_group = &self.mig.segment_groups[mig_group_idx];
137
138            // Check if this starts a variant set
139            if mig_group.variant_code.is_some() {
140                let variant_count = self.mig.segment_groups[mig_group_idx..]
141                    .iter()
142                    .take_while(|g| g.id == mig_group.id && g.variant_code.is_some())
143                    .count();
144                let variant_defs =
145                    &self.mig.segment_groups[mig_group_idx..mig_group_idx + variant_count];
146
147                if let Some(idx) = tree
148                    .groups
149                    .iter()
150                    .enumerate()
151                    .position(|(i, g)| !consumed_groups[i] && g.group_id == mig_group.id)
152                {
153                    if let Some(inter_segs) = tree.inter_group_segments.get(&idx) {
154                        for seg in inter_segs {
155                            emit.push(seg);
156                        }
157                    }
158                    self.emit_variant_group(&tree.groups[idx], variant_defs, &mut emit);
159                    consumed_groups[idx] = true;
160                }
161                mig_group_idx += variant_count;
162            } else {
163                if let Some(idx) = tree
164                    .groups
165                    .iter()
166                    .enumerate()
167                    .position(|(i, g)| !consumed_groups[i] && g.group_id == mig_group.id)
168                {
169                    // Emit any inter-group segments that precede this group
170                    if let Some(inter_segs) = tree.inter_group_segments.get(&idx) {
171                        for seg in inter_segs {
172                            emit.push(seg);
173                        }
174                    }
175                    self.emit_group(&tree.groups[idx], mig_group, &mut emit);
176                    consumed_groups[idx] = true;
177                }
178                mig_group_idx += 1;
179            }
180        }
181
182        // 2b. Emit trailing inter-group segments (after the last group).
183        //     For ORDERS, UNS+S comes after SG29 (detail/summary boundary).
184        let trailing_idx = tree.groups.len();
185        if let Some(inter_segs) = tree.inter_group_segments.get(&trailing_idx) {
186            for seg in inter_segs {
187                emit.push(seg);
188            }
189        }
190
191        // 3. Emit post-group segments (e.g., UNT, UNZ)
192        for seg in &tree.segments[tree.post_group_start..] {
193            emit.push(seg);
194        }
195
196        emit
197    }
198
199    fn emit_group(&self, group: &AssembledGroup, mig_group: &MigSegmentGroup, emit: &mut Emit) {
200        for instance in &group.repetitions {
201            self.emit_group_instance(instance, mig_group, emit);
202        }
203    }
204
205    /// Emit a group whose reps come from multiple variant definitions.
206    ///
207    /// For each rep, determine which variant it belongs to by applying the same
208    /// test the assembler uses: the qualifier at the variant's
209    /// `variant_qualifier_position` must be one of the codes that variant allows
210    /// (`variant_codes`, falling back to the single `variant_code`).
211    /// Then emit the rep using that variant's segment/nested-group ordering.
212    ///
213    /// Matching the variant's *first* code only routes a rep to the wrong
214    /// variant definition whenever a variant allows several codes (the UTILMD
215    /// SG8 variant allowing `Z01, Z80, Z81, Z98`) or reads its qualifier at
216    /// another position (CCI variants). The rep's nested groups are then looked
217    /// up in a definition that does not declare them and are silently dropped —
218    /// the SG9 `QTY` under `SEQ+Z98` in UTILMD 55013.
219    fn emit_variant_group(
220        &self,
221        group: &AssembledGroup,
222        variant_defs: &[MigSegmentGroup],
223        emit: &mut Emit,
224    ) {
225        for instance in &group.repetitions {
226            let matches_variant = |v: &MigSegmentGroup| {
227                let (ei, ci) = v.variant_qualifier_position.unwrap_or((0, 0));
228                // Find the entry segment by tag: a reverse-built repetition
229                // holds its segments in definition order, which need not start
230                // with the entry segment.
231                let entry_tag = v.segments.first().map(|s| s.id.as_str());
232                let qual = instance
233                    .segments
234                    .iter()
235                    .find(|s| Some(s.tag.as_str()) == entry_tag)
236                    .or_else(|| instance.segments.first())
237                    .and_then(|s| s.elements.get(ei))
238                    .and_then(|e| e.get(ci))
239                    .map(|v| v.as_str())
240                    .unwrap_or("");
241                if !v.variant_codes.is_empty() {
242                    v.variant_codes.iter().any(|c| c.eq_ignore_ascii_case(qual))
243                } else {
244                    v.variant_code
245                        .as_deref()
246                        .is_some_and(|vc| vc.eq_ignore_ascii_case(qual))
247                }
248            };
249
250            let variant_def = variant_defs
251                .iter()
252                .find(|v| matches_variant(v))
253                .unwrap_or(&variant_defs[0]); // fallback to first variant
254
255            self.emit_group_instance(instance, variant_def, emit);
256        }
257    }
258
259    /// Record `instance` if it has content but not `mig_group`'s entry segment,
260    /// and return its `(group_path, source_path)`.
261    fn check_entry_segment(
262        instance: &AssembledGroupInstance,
263        mig_group: &MigSegmentGroup,
264        emit: &mut Emit,
265    ) -> (String, String) {
266        let join = |parent: Option<&String>, child: String| match parent {
267            Some(p) => format!("{p}.{child}"),
268            None => child,
269        };
270        let parent = emit.path.last();
271        let group_path = join(parent.map(|p| &p.0), mig_group.id.clone());
272        let missing_entry = mig_group.segments.first().filter(|entry| {
273            let has_content = !instance.segments.is_empty()
274                || instance
275                    .child_groups
276                    .iter()
277                    .any(|g| !g.repetitions.is_empty());
278            has_content && !instance.segments.iter().any(|s| s.tag == entry.id)
279        });
280        // The variant is identified by the entry segment's qualifier, so without
281        // an entry segment `mig_group` is only a fallback guess — leave the
282        // variant out of the path rather than report a wrong one.
283        let own_source = match &mig_group.variant_code {
284            Some(vc) if missing_entry.is_none() => {
285                format!("{}_{}", mig_group.id, vc).to_lowercase()
286            }
287            _ => mig_group.id.to_lowercase(),
288        };
289        let source_path = join(parent.map(|p| &p.1), own_source);
290
291        if let Some(entry) = missing_entry {
292            emit.missing.push(MissingEntrySegment {
293                group_path: group_path.clone(),
294                source_path: source_path.clone(),
295                entry_segment: entry.id.clone(),
296                present_segments: instance.segments.iter().map(|s| s.tag.clone()).collect(),
297            });
298        }
299        (group_path, source_path)
300    }
301
302    fn emit_group_instance(
303        &self,
304        instance: &AssembledGroupInstance,
305        mig_group: &MigSegmentGroup,
306        emit: &mut Emit,
307    ) {
308        let paths = Self::check_entry_segment(instance, mig_group, emit);
309        emit.path.push(paths);
310
311        // Emit segments in MIG order using tag-based lookup with consumption tracking.
312        // This handles both assembler output (in MIG order) and reverse-mapped trees
313        // (may be in arbitrary order).
314        //
315        // After MIG-guided emission, any remaining unconsumed segments are appended.
316        // This handles cases where the assembler captured more segments than the MIG
317        // defines (e.g., 6 RFFs when the merged MIG only has 4 slots).
318        let mut consumed = vec![false; instance.segments.len()];
319        for mig_seg in &mig_group.segments {
320            if let Some(idx) = instance
321                .segments
322                .iter()
323                .enumerate()
324                .position(|(i, s)| !consumed[i] && s.tag == mig_seg.id)
325            {
326                emit.push(&instance.segments[idx]);
327                consumed[idx] = true;
328            }
329        }
330        // Emit any remaining segments not matched by MIG slots
331        for (i, seg) in instance.segments.iter().enumerate() {
332            if !consumed[i] {
333                emit.push(seg);
334            }
335        }
336
337        // Re-emit skipped segments (unknown segments preserved for roundtrip fidelity)
338        for skipped in &instance.skipped_segments {
339            emit.push(skipped);
340        }
341
342        // Child groups — lookup by group ID with consumption tracking.
343        // Applies variant-aware logic recursively for nested variant groups.
344        let mut consumed_child = vec![false; instance.child_groups.len()];
345        let mut nested_idx = 0;
346        while nested_idx < mig_group.nested_groups.len() {
347            let nested_mig = &mig_group.nested_groups[nested_idx];
348
349            if nested_mig.variant_code.is_some() {
350                let variant_count = mig_group.nested_groups[nested_idx..]
351                    .iter()
352                    .take_while(|g| g.id == nested_mig.id && g.variant_code.is_some())
353                    .count();
354                let variant_defs = &mig_group.nested_groups[nested_idx..nested_idx + variant_count];
355
356                if let Some(idx) = instance
357                    .child_groups
358                    .iter()
359                    .enumerate()
360                    .position(|(i, g)| !consumed_child[i] && g.group_id == nested_mig.id)
361                {
362                    self.emit_variant_group(&instance.child_groups[idx], variant_defs, emit);
363                    consumed_child[idx] = true;
364                }
365                nested_idx += variant_count;
366            } else {
367                if let Some(idx) = instance
368                    .child_groups
369                    .iter()
370                    .enumerate()
371                    .position(|(i, g)| !consumed_child[i] && g.group_id == nested_mig.id)
372                {
373                    self.emit_group(&instance.child_groups[idx], nested_mig, emit);
374                    consumed_child[idx] = true;
375                }
376                nested_idx += 1;
377            }
378        }
379
380        emit.path.pop();
381    }
382}
383
384fn assembled_to_disassembled(seg: &AssembledSegment) -> DisassembledSegment {
385    DisassembledSegment {
386        tag: seg.tag.clone(),
387        elements: seg.elements.clone(),
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::assembler::{
395        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
396    };
397    use crate::test_support::{make_mig_group, make_mig_segment};
398    use mig_types::schema::mig::MigSchema;
399
400    #[test]
401    fn test_disassemble_top_level_only() {
402        let mig = MigSchema {
403            message_type: "UTILMD".to_string(),
404            variant: Some("Strom".to_string()),
405            version: "S2.1".to_string(),
406            publication_date: "2025-03-20".to_string(),
407            author: "BDEW".to_string(),
408            format_version: "FV2504".to_string(),
409            source_file: "test".to_string(),
410            segments: vec![make_mig_segment("UNH"), make_mig_segment("BGM")],
411            segment_groups: vec![],
412        };
413
414        let tree = AssembledTree {
415            segments: vec![
416                AssembledSegment {
417                    tag: "UNH".to_string(),
418                    elements: vec![
419                        vec!["1".to_string()],
420                        vec![
421                            "UTILMD".to_string(),
422                            "D".to_string(),
423                            "11A".to_string(),
424                            "UN".to_string(),
425                            "S2.1".to_string(),
426                        ],
427                    ],
428                    mig_number: None,
429                    segment_number: None,
430                },
431                AssembledSegment {
432                    tag: "BGM".to_string(),
433                    elements: vec![
434                        vec!["E01".to_string()],
435                        vec!["MSG001".to_string()],
436                        vec!["9".to_string()],
437                    ],
438                    mig_number: None,
439                    segment_number: None,
440                },
441            ],
442            groups: vec![],
443            post_group_start: 2,
444            inter_group_segments: std::collections::BTreeMap::new(),
445        };
446
447        let disassembler = Disassembler::new(&mig);
448        let segments = disassembler.disassemble(&tree);
449
450        assert_eq!(segments.len(), 2);
451        assert_eq!(segments[0].tag, "UNH");
452        assert_eq!(segments[1].tag, "BGM");
453        assert_eq!(segments[0].elements[0], vec!["1"]);
454    }
455
456    #[test]
457    fn test_disassemble_with_groups() {
458        let mig = MigSchema {
459            message_type: "UTILMD".to_string(),
460            variant: None,
461            version: "S2.1".to_string(),
462            publication_date: "".to_string(),
463            author: "".to_string(),
464            format_version: "FV2504".to_string(),
465            source_file: "test".to_string(),
466            segments: vec![make_mig_segment("UNH"), make_mig_segment("BGM")],
467            segment_groups: vec![make_mig_group("SG2", vec!["NAD", "LOC"], vec![])],
468        };
469
470        let tree = AssembledTree {
471            segments: vec![
472                AssembledSegment {
473                    tag: "UNH".to_string(),
474                    elements: vec![vec!["1".to_string()]],
475                    mig_number: None,
476                    segment_number: None,
477                },
478                AssembledSegment {
479                    tag: "BGM".to_string(),
480                    elements: vec![vec!["E01".to_string()]],
481                    mig_number: None,
482                    segment_number: None,
483                },
484            ],
485            post_group_start: 2,
486            groups: vec![AssembledGroup {
487                group_id: "SG2".to_string(),
488                repetitions: vec![
489                    AssembledGroupInstance {
490                        segments: vec![AssembledSegment {
491                            tag: "NAD".to_string(),
492                            elements: vec![vec!["MS".to_string()]],
493                            mig_number: None,
494                            segment_number: None,
495                        }],
496                        child_groups: vec![],
497                        entry_mig_number: None,
498                        variant_mig_numbers: vec![],
499                        skipped_segments: vec![],
500                        skipped_positions: Vec::new(),
501                    },
502                    AssembledGroupInstance {
503                        segments: vec![AssembledSegment {
504                            tag: "NAD".to_string(),
505                            elements: vec![vec!["MR".to_string()]],
506                            mig_number: None,
507                            segment_number: None,
508                        }],
509                        child_groups: vec![],
510                        entry_mig_number: None,
511                        variant_mig_numbers: vec![],
512                        skipped_segments: vec![],
513                        skipped_positions: Vec::new(),
514                    },
515                ],
516            }],
517            inter_group_segments: std::collections::BTreeMap::new(),
518        };
519
520        let disassembler = Disassembler::new(&mig);
521        let segments = disassembler.disassemble(&tree);
522
523        assert_eq!(segments.len(), 4); // UNH, BGM, NAD(MS), NAD(MR)
524        assert_eq!(segments[0].tag, "UNH");
525        assert_eq!(segments[1].tag, "BGM");
526        assert_eq!(segments[2].tag, "NAD");
527        assert_eq!(segments[2].elements[0][0], "MS");
528        assert_eq!(segments[3].tag, "NAD");
529        assert_eq!(segments[3].elements[0][0], "MR");
530    }
531
532    #[test]
533    fn test_disassemble_nested_groups() {
534        let sg3 = make_mig_group("SG3", vec!["CTA", "COM"], vec![]);
535        let mig = MigSchema {
536            message_type: "UTILMD".to_string(),
537            variant: None,
538            version: "S2.1".to_string(),
539            publication_date: "".to_string(),
540            author: "".to_string(),
541            format_version: "FV2504".to_string(),
542            source_file: "test".to_string(),
543            segments: vec![make_mig_segment("UNH")],
544            segment_groups: vec![make_mig_group("SG2", vec!["NAD"], vec![sg3])],
545        };
546
547        let tree = AssembledTree {
548            segments: vec![AssembledSegment {
549                tag: "UNH".to_string(),
550                elements: vec![vec!["1".to_string()]],
551                mig_number: None,
552                segment_number: None,
553            }],
554            post_group_start: 1,
555            groups: vec![AssembledGroup {
556                group_id: "SG2".to_string(),
557                repetitions: vec![AssembledGroupInstance {
558                    segments: vec![AssembledSegment {
559                        tag: "NAD".to_string(),
560                        elements: vec![vec!["MS".to_string()]],
561                        mig_number: None,
562                        segment_number: None,
563                    }],
564                    child_groups: vec![AssembledGroup {
565                        group_id: "SG3".to_string(),
566                        repetitions: vec![AssembledGroupInstance {
567                            segments: vec![
568                                AssembledSegment {
569                                    tag: "CTA".to_string(),
570                                    elements: vec![vec!["IC".to_string()]],
571                                    mig_number: None,
572                                    segment_number: None,
573                                },
574                                AssembledSegment {
575                                    tag: "COM".to_string(),
576                                    elements: vec![vec![
577                                        "040@ex.com".to_string(),
578                                        "EM".to_string(),
579                                    ]],
580                                    mig_number: None,
581                                    segment_number: None,
582                                },
583                            ],
584                            child_groups: vec![],
585                            entry_mig_number: None,
586                            variant_mig_numbers: vec![],
587                            skipped_segments: vec![],
588                            skipped_positions: Vec::new(),
589                        }],
590                    }],
591                    entry_mig_number: None,
592                    variant_mig_numbers: vec![],
593                    skipped_segments: vec![],
594                    skipped_positions: Vec::new(),
595                }],
596            }],
597            inter_group_segments: std::collections::BTreeMap::new(),
598        };
599
600        let disassembler = Disassembler::new(&mig);
601        let segments = disassembler.disassemble(&tree);
602
603        assert_eq!(segments.len(), 4); // UNH, NAD, CTA, COM
604        assert_eq!(segments[0].tag, "UNH");
605        assert_eq!(segments[1].tag, "NAD");
606        assert_eq!(segments[2].tag, "CTA");
607        assert_eq!(segments[3].tag, "COM");
608    }
609
610    #[test]
611    fn test_disassemble_variant_groups_uses_per_variant_ordering() {
612        use crate::test_support::make_mig_group_with_variant;
613
614        // Two SG8 variant definitions with different segment ordering:
615        // - ZD7 variant: SEQ, RFF, DTM
616        // - Z98 variant: SEQ, CCI, CAV
617        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF", "DTM"], vec![], "ZD7");
618        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI", "CAV"], vec![], "Z98");
619
620        let mig = MigSchema {
621            message_type: "UTILMD".to_string(),
622            variant: None,
623            version: "S2.1".to_string(),
624            publication_date: "".to_string(),
625            author: "".to_string(),
626            format_version: "FV2504".to_string(),
627            source_file: "test".to_string(),
628            segments: vec![make_mig_segment("UNH")],
629            segment_groups: vec![sg8_zd7, sg8_z98],
630        };
631
632        // Assembled tree has 3 reps: ZD7, Z98, ZD7 (interleaved)
633        let tree = AssembledTree {
634            segments: vec![AssembledSegment {
635                tag: "UNH".to_string(),
636                elements: vec![vec!["1".to_string()]],
637                mig_number: None,
638                segment_number: None,
639            }],
640            post_group_start: 1,
641            groups: vec![AssembledGroup {
642                group_id: "SG8".to_string(),
643                repetitions: vec![
644                    // Rep 0: ZD7 variant — has SEQ, DTM, RFF (out of MIG order)
645                    AssembledGroupInstance {
646                        segments: vec![
647                            AssembledSegment {
648                                tag: "SEQ".to_string(),
649                                elements: vec![vec!["ZD7".to_string()]],
650                                mig_number: None,
651                                segment_number: None,
652                            },
653                            AssembledSegment {
654                                tag: "DTM".to_string(),
655                                elements: vec![vec!["303".to_string()]],
656                                mig_number: None,
657                                segment_number: None,
658                            },
659                            AssembledSegment {
660                                tag: "RFF".to_string(),
661                                elements: vec![vec!["Z13".to_string()]],
662                                mig_number: None,
663                                segment_number: None,
664                            },
665                        ],
666                        child_groups: vec![],
667                        entry_mig_number: None,
668                        variant_mig_numbers: vec![],
669                        skipped_segments: vec![],
670                        skipped_positions: Vec::new(),
671                    },
672                    // Rep 1: Z98 variant — has SEQ, CAV, CCI (out of MIG order)
673                    AssembledGroupInstance {
674                        segments: vec![
675                            AssembledSegment {
676                                tag: "SEQ".to_string(),
677                                elements: vec![vec!["Z98".to_string()]],
678                                mig_number: None,
679                                segment_number: None,
680                            },
681                            AssembledSegment {
682                                tag: "CAV".to_string(),
683                                elements: vec![vec!["Z91".to_string()]],
684                                mig_number: None,
685                                segment_number: None,
686                            },
687                            AssembledSegment {
688                                tag: "CCI".to_string(),
689                                elements: vec![vec!["".to_string()]],
690                                mig_number: None,
691                                segment_number: None,
692                            },
693                        ],
694                        child_groups: vec![],
695                        entry_mig_number: None,
696                        variant_mig_numbers: vec![],
697                        skipped_segments: vec![],
698                        skipped_positions: Vec::new(),
699                    },
700                    // Rep 2: another ZD7 variant
701                    AssembledGroupInstance {
702                        segments: vec![
703                            AssembledSegment {
704                                tag: "SEQ".to_string(),
705                                elements: vec![vec!["ZD7".to_string()]],
706                                mig_number: None,
707                                segment_number: None,
708                            },
709                            AssembledSegment {
710                                tag: "RFF".to_string(),
711                                elements: vec![vec!["Z34".to_string()]],
712                                mig_number: None,
713                                segment_number: None,
714                            },
715                        ],
716                        child_groups: vec![],
717                        entry_mig_number: None,
718                        variant_mig_numbers: vec![],
719                        skipped_segments: vec![],
720                        skipped_positions: Vec::new(),
721                    },
722                ],
723            }],
724            inter_group_segments: std::collections::BTreeMap::new(),
725        };
726
727        let disassembler = Disassembler::new(&mig);
728        let segments = disassembler.disassemble(&tree);
729
730        // UNH + 3 reps
731        assert_eq!(segments[0].tag, "UNH");
732
733        // Rep 0 (ZD7): MIG order is SEQ, RFF, DTM
734        assert_eq!(segments[1].tag, "SEQ");
735        assert_eq!(segments[1].elements[0][0], "ZD7");
736        assert_eq!(segments[2].tag, "RFF"); // reordered from position 3 to 2
737        assert_eq!(segments[3].tag, "DTM"); // reordered from position 2 to 3
738
739        // Rep 1 (Z98): MIG order is SEQ, CCI, CAV
740        assert_eq!(segments[4].tag, "SEQ");
741        assert_eq!(segments[4].elements[0][0], "Z98");
742        assert_eq!(segments[5].tag, "CCI"); // reordered from position 3 to 2
743        assert_eq!(segments[6].tag, "CAV"); // reordered from position 2 to 3
744
745        // Rep 2 (ZD7): SEQ, RFF (no DTM)
746        assert_eq!(segments[7].tag, "SEQ");
747        assert_eq!(segments[7].elements[0][0], "ZD7");
748        assert_eq!(segments[8].tag, "RFF");
749
750        assert_eq!(segments.len(), 9);
751    }
752
753    /// A repetition belongs to the variant whose *allowed* qualifier codes
754    /// (`variant_codes`, read at `variant_qualifier_position`) contain its entry
755    /// qualifier — not only to the variant whose first code (`variant_code`) it
756    /// happens to carry. Otherwise the rep is emitted with the wrong variant
757    /// definition and its nested groups, which only the correct variant
758    /// declares, are silently dropped (UTILMD 55013: SG9 `QTY` under `SEQ+Z98`,
759    /// whose SG8 variant allows `Z01, Z80, Z81, Z98`).
760    #[test]
761    fn test_disassemble_variant_group_matches_any_allowed_code() {
762        use crate::test_support::make_mig_group_with_variant;
763
764        let seg = |tag: &str, qual: &str| AssembledSegment {
765            tag: tag.to_string(),
766            elements: vec![vec![qual.to_string()]],
767            mig_number: None,
768            segment_number: None,
769        };
770        let instance = |segments: Vec<AssembledSegment>, child_groups: Vec<AssembledGroup>| {
771            AssembledGroupInstance {
772                segments,
773                child_groups,
774                entry_mig_number: None,
775                variant_mig_numbers: vec![],
776                skipped_segments: vec![],
777                skipped_positions: Vec::new(),
778            }
779        };
780        let group = |id: &str, reps: Vec<AssembledGroupInstance>| AssembledGroup {
781            group_id: id.to_string(),
782            repetitions: reps,
783        };
784
785        // Variant 1: SEQ+ZD7, no SG9. Variant 2: SEQ+{Z01,Z98} with SG9.
786        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "ZD7");
787        let mut sg8_z01 = make_mig_group_with_variant(
788            "SG8",
789            vec!["SEQ"],
790            vec![make_mig_group("SG9", vec!["QTY"], vec![])],
791            "Z01",
792        );
793        sg8_z01.variant_codes = vec!["Z01".to_string(), "Z98".to_string()];
794
795        let mig = MigSchema {
796            message_type: "UTILMD".to_string(),
797            variant: None,
798            version: "S2.1".to_string(),
799            publication_date: String::new(),
800            author: String::new(),
801            format_version: "FV2504".to_string(),
802            source_file: "test".to_string(),
803            segments: vec![],
804            segment_groups: vec![sg8_zd7, sg8_z01],
805        };
806
807        let tree = AssembledTree {
808            segments: vec![],
809            post_group_start: 0,
810            groups: vec![group(
811                "SG8",
812                vec![instance(
813                    vec![seg("SEQ", "Z98")],
814                    vec![group("SG9", vec![instance(vec![seg("QTY", "31")], vec![])])],
815                )],
816            )],
817            inter_group_segments: std::collections::BTreeMap::new(),
818        };
819
820        let tags: Vec<String> = Disassembler::new(&mig)
821            .disassemble(&tree)
822            .iter()
823            .map(|s| s.tag.clone())
824            .collect();
825        assert_eq!(tags, ["SEQ", "QTY"], "SG9 under SEQ+Z98 must survive");
826    }
827
828    #[test]
829    fn test_disassemble_empty_tree() {
830        let mig = MigSchema {
831            message_type: "UTILMD".to_string(),
832            variant: None,
833            version: "S2.1".to_string(),
834            publication_date: "".to_string(),
835            author: "".to_string(),
836            format_version: "FV2504".to_string(),
837            source_file: "test".to_string(),
838            segments: vec![make_mig_segment("UNH")],
839            segment_groups: vec![],
840        };
841
842        let tree = AssembledTree {
843            segments: vec![],
844            groups: vec![],
845            post_group_start: 0,
846            inter_group_segments: std::collections::BTreeMap::new(),
847        };
848
849        let disassembler = Disassembler::new(&mig);
850        let segments = disassembler.disassemble(&tree);
851        assert!(segments.is_empty());
852    }
853
854    fn seg(tag: &str, first: &str) -> AssembledSegment {
855        AssembledSegment {
856            tag: tag.to_string(),
857            elements: vec![vec![first.to_string()]],
858            mig_number: None,
859            segment_number: None,
860        }
861    }
862
863    fn instance(
864        segments: Vec<AssembledSegment>,
865        child_groups: Vec<AssembledGroup>,
866    ) -> AssembledGroupInstance {
867        AssembledGroupInstance {
868            segments,
869            child_groups,
870            entry_mig_number: None,
871            variant_mig_numbers: vec![],
872            skipped_segments: vec![],
873            skipped_positions: vec![],
874        }
875    }
876
877    /// UNH, SG4[IDE] > SG8_Z03[SEQ] > SG10[CCI, CAV] — the #103 shape.
878    fn sg10_mig_and_tree(sg10: AssembledGroupInstance) -> (MigSchema, AssembledTree) {
879        let sg10_mig = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
880        let sg8_mig = crate::test_support::make_mig_group_with_variant(
881            "SG8",
882            vec!["SEQ"],
883            vec![sg10_mig],
884            "Z03",
885        );
886        let sg4_mig = make_mig_group("SG4", vec!["IDE"], vec![sg8_mig]);
887        let mig = MigSchema {
888            message_type: "UTILMD".to_string(),
889            variant: None,
890            version: "S2.1".to_string(),
891            publication_date: "".to_string(),
892            author: "".to_string(),
893            format_version: "FV2604".to_string(),
894            source_file: "test".to_string(),
895            segments: vec![make_mig_segment("UNH")],
896            segment_groups: vec![sg4_mig],
897        };
898        let sg8 = instance(
899            vec![seg("SEQ", "Z03")],
900            vec![AssembledGroup {
901                group_id: "SG10".to_string(),
902                repetitions: vec![sg10],
903            }],
904        );
905        let sg4 = instance(
906            vec![seg("IDE", "24")],
907            vec![AssembledGroup {
908                group_id: "SG8".to_string(),
909                repetitions: vec![sg8],
910            }],
911        );
912        let tree = AssembledTree {
913            segments: vec![seg("UNH", "1")],
914            groups: vec![AssembledGroup {
915                group_id: "SG4".to_string(),
916                repetitions: vec![sg4],
917            }],
918            post_group_start: 1,
919            inter_group_segments: std::collections::BTreeMap::new(),
920        };
921        (mig, tree)
922    }
923
924    #[test]
925    fn checked_disassembly_refuses_group_without_entry_segment() {
926        let (mig, tree) = sg10_mig_and_tree(instance(vec![seg("CAV", "Z30")], vec![]));
927        let disassembler = Disassembler::new(&mig);
928
929        // Lenient disassembly still emits it (tooling, roundtrip of raw trees).
930        let tags: Vec<_> = disassembler
931            .disassemble(&tree)
932            .into_iter()
933            .map(|s| s.tag)
934            .collect();
935        assert_eq!(tags, ["UNH", "IDE", "SEQ", "CAV"]);
936
937        let expected = MissingEntrySegment {
938            group_path: "SG4.SG8.SG10".to_string(),
939            source_path: "sg4.sg8_z03.sg10".to_string(),
940            entry_segment: "CCI".to_string(),
941            present_segments: vec!["CAV".to_string()],
942        };
943        assert_eq!(
944            disassembler.missing_entry_segments(&tree),
945            vec![expected.clone()]
946        );
947        match disassembler.disassemble_checked(&tree) {
948            Err(AssemblyError::MissingGroupEntrySegment {
949                group_path,
950                source_path,
951                entry_segment,
952                present_segments,
953            }) => {
954                assert_eq!(group_path, expected.group_path);
955                assert_eq!(source_path, expected.source_path);
956                assert_eq!(entry_segment, expected.entry_segment);
957                assert_eq!(present_segments, expected.present_segments);
958            }
959            other => panic!("expected MissingGroupEntrySegment, got {other:?}"),
960        }
961    }
962
963    #[test]
964    fn checked_disassembly_accepts_complete_groups() {
965        let (mig, tree) =
966            sg10_mig_and_tree(instance(vec![seg("CCI", ""), seg("CAV", "Z30")], vec![]));
967        let disassembler = Disassembler::new(&mig);
968        assert!(disassembler.missing_entry_segments(&tree).is_empty());
969        let tags: Vec<_> = disassembler
970            .disassemble_checked(&tree)
971            .expect("complete tree")
972            .into_iter()
973            .map(|s| s.tag)
974            .collect();
975        assert_eq!(tags, ["UNH", "IDE", "SEQ", "CCI", "CAV"]);
976    }
977
978    #[test]
979    fn empty_group_instance_is_not_a_missing_entry_segment() {
980        let (mig, tree) = sg10_mig_and_tree(instance(vec![], vec![]));
981        assert!(Disassembler::new(&mig)
982            .missing_entry_segments(&tree)
983            .is_empty());
984    }
985}