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        //
319        // A repeated non-entry segment stays in its place, as the assembler
320        // reads it: after a run of same-tag slots, the remaining segments of
321        // that tag follow the run, unless a later run of the tag takes them
322        // (UTILTS SG5 `STS STS FTX FTX` has one STS slot and one FTX slot).
323        let mut consumed = vec![false; instance.segments.len()];
324        for (slot_idx, mig_seg) in mig_group.segments.iter().enumerate() {
325            if let Some(idx) = instance
326                .segments
327                .iter()
328                .enumerate()
329                .position(|(i, s)| !consumed[i] && s.tag == mig_seg.id)
330            {
331                emit.push(&instance.segments[idx]);
332                consumed[idx] = true;
333            }
334            let rest = &mig_group.segments[slot_idx + 1..];
335            let is_entry_run = mig_group.segments[..=slot_idx]
336                .iter()
337                .all(|s| s.id == mig_seg.id);
338            if is_entry_run || rest.iter().any(|s| s.id == mig_seg.id) {
339                continue;
340            }
341            for (i, seg) in instance.segments.iter().enumerate() {
342                if !consumed[i] && seg.tag == mig_seg.id {
343                    emit.push(seg);
344                    consumed[i] = true;
345                }
346            }
347        }
348        // Emit any remaining segments not matched by MIG slots
349        for (i, seg) in instance.segments.iter().enumerate() {
350            if !consumed[i] {
351                emit.push(seg);
352            }
353        }
354
355        // Re-emit skipped segments (unknown segments preserved for roundtrip fidelity)
356        for skipped in &instance.skipped_segments {
357            emit.push(skipped);
358        }
359
360        // Child groups — lookup by group ID with consumption tracking.
361        // Applies variant-aware logic recursively for nested variant groups.
362        let mut consumed_child = vec![false; instance.child_groups.len()];
363        let mut nested_idx = 0;
364        while nested_idx < mig_group.nested_groups.len() {
365            let nested_mig = &mig_group.nested_groups[nested_idx];
366
367            if nested_mig.variant_code.is_some() {
368                let variant_count = mig_group.nested_groups[nested_idx..]
369                    .iter()
370                    .take_while(|g| g.id == nested_mig.id && g.variant_code.is_some())
371                    .count();
372                let variant_defs = &mig_group.nested_groups[nested_idx..nested_idx + variant_count];
373
374                if let Some(idx) = instance
375                    .child_groups
376                    .iter()
377                    .enumerate()
378                    .position(|(i, g)| !consumed_child[i] && g.group_id == nested_mig.id)
379                {
380                    self.emit_variant_group(&instance.child_groups[idx], variant_defs, emit);
381                    consumed_child[idx] = true;
382                }
383                nested_idx += variant_count;
384            } else {
385                if let Some(idx) = instance
386                    .child_groups
387                    .iter()
388                    .enumerate()
389                    .position(|(i, g)| !consumed_child[i] && g.group_id == nested_mig.id)
390                {
391                    self.emit_group(&instance.child_groups[idx], nested_mig, emit);
392                    consumed_child[idx] = true;
393                }
394                nested_idx += 1;
395            }
396        }
397
398        emit.path.pop();
399    }
400}
401
402fn assembled_to_disassembled(seg: &AssembledSegment) -> DisassembledSegment {
403    DisassembledSegment {
404        tag: seg.tag.clone(),
405        elements: seg.elements.clone(),
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::assembler::{
413        AssembledGroup, AssembledGroupInstance, AssembledSegment, AssembledTree,
414    };
415    use crate::test_support::{make_mig_group, make_mig_segment};
416    use mig_types::schema::mig::MigSchema;
417
418    #[test]
419    fn test_disassemble_top_level_only() {
420        let mig = MigSchema {
421            message_type: "UTILMD".to_string(),
422            variant: Some("Strom".to_string()),
423            version: "S2.1".to_string(),
424            publication_date: "2025-03-20".to_string(),
425            author: "BDEW".to_string(),
426            format_version: "FV2504".to_string(),
427            source_file: "test".to_string(),
428            segments: vec![make_mig_segment("UNH"), make_mig_segment("BGM")],
429            segment_groups: vec![],
430        };
431
432        let tree = AssembledTree {
433            segments: vec![
434                AssembledSegment {
435                    tag: "UNH".to_string(),
436                    elements: vec![
437                        vec!["1".to_string()],
438                        vec![
439                            "UTILMD".to_string(),
440                            "D".to_string(),
441                            "11A".to_string(),
442                            "UN".to_string(),
443                            "S2.1".to_string(),
444                        ],
445                    ],
446                    mig_number: None,
447                    segment_number: None,
448                },
449                AssembledSegment {
450                    tag: "BGM".to_string(),
451                    elements: vec![
452                        vec!["E01".to_string()],
453                        vec!["MSG001".to_string()],
454                        vec!["9".to_string()],
455                    ],
456                    mig_number: None,
457                    segment_number: None,
458                },
459            ],
460            groups: vec![],
461            post_group_start: 2,
462            inter_group_segments: std::collections::BTreeMap::new(),
463        };
464
465        let disassembler = Disassembler::new(&mig);
466        let segments = disassembler.disassemble(&tree);
467
468        assert_eq!(segments.len(), 2);
469        assert_eq!(segments[0].tag, "UNH");
470        assert_eq!(segments[1].tag, "BGM");
471        assert_eq!(segments[0].elements[0], vec!["1"]);
472    }
473
474    #[test]
475    fn test_disassemble_with_groups() {
476        let mig = MigSchema {
477            message_type: "UTILMD".to_string(),
478            variant: None,
479            version: "S2.1".to_string(),
480            publication_date: "".to_string(),
481            author: "".to_string(),
482            format_version: "FV2504".to_string(),
483            source_file: "test".to_string(),
484            segments: vec![make_mig_segment("UNH"), make_mig_segment("BGM")],
485            segment_groups: vec![make_mig_group("SG2", vec!["NAD", "LOC"], vec![])],
486        };
487
488        let tree = AssembledTree {
489            segments: vec![
490                AssembledSegment {
491                    tag: "UNH".to_string(),
492                    elements: vec![vec!["1".to_string()]],
493                    mig_number: None,
494                    segment_number: None,
495                },
496                AssembledSegment {
497                    tag: "BGM".to_string(),
498                    elements: vec![vec!["E01".to_string()]],
499                    mig_number: None,
500                    segment_number: None,
501                },
502            ],
503            post_group_start: 2,
504            groups: vec![AssembledGroup {
505                group_id: "SG2".to_string(),
506                repetitions: vec![
507                    AssembledGroupInstance {
508                        segments: vec![AssembledSegment {
509                            tag: "NAD".to_string(),
510                            elements: vec![vec!["MS".to_string()]],
511                            mig_number: None,
512                            segment_number: None,
513                        }],
514                        child_groups: vec![],
515                        entry_mig_number: None,
516                        variant_mig_numbers: vec![],
517                        skipped_segments: vec![],
518                        skipped_positions: Vec::new(),
519                    },
520                    AssembledGroupInstance {
521                        segments: vec![AssembledSegment {
522                            tag: "NAD".to_string(),
523                            elements: vec![vec!["MR".to_string()]],
524                            mig_number: None,
525                            segment_number: None,
526                        }],
527                        child_groups: vec![],
528                        entry_mig_number: None,
529                        variant_mig_numbers: vec![],
530                        skipped_segments: vec![],
531                        skipped_positions: Vec::new(),
532                    },
533                ],
534            }],
535            inter_group_segments: std::collections::BTreeMap::new(),
536        };
537
538        let disassembler = Disassembler::new(&mig);
539        let segments = disassembler.disassemble(&tree);
540
541        assert_eq!(segments.len(), 4); // UNH, BGM, NAD(MS), NAD(MR)
542        assert_eq!(segments[0].tag, "UNH");
543        assert_eq!(segments[1].tag, "BGM");
544        assert_eq!(segments[2].tag, "NAD");
545        assert_eq!(segments[2].elements[0][0], "MS");
546        assert_eq!(segments[3].tag, "NAD");
547        assert_eq!(segments[3].elements[0][0], "MR");
548    }
549
550    #[test]
551    fn test_disassemble_nested_groups() {
552        let sg3 = make_mig_group("SG3", vec!["CTA", "COM"], vec![]);
553        let mig = MigSchema {
554            message_type: "UTILMD".to_string(),
555            variant: None,
556            version: "S2.1".to_string(),
557            publication_date: "".to_string(),
558            author: "".to_string(),
559            format_version: "FV2504".to_string(),
560            source_file: "test".to_string(),
561            segments: vec![make_mig_segment("UNH")],
562            segment_groups: vec![make_mig_group("SG2", vec!["NAD"], vec![sg3])],
563        };
564
565        let tree = AssembledTree {
566            segments: vec![AssembledSegment {
567                tag: "UNH".to_string(),
568                elements: vec![vec!["1".to_string()]],
569                mig_number: None,
570                segment_number: None,
571            }],
572            post_group_start: 1,
573            groups: vec![AssembledGroup {
574                group_id: "SG2".to_string(),
575                repetitions: vec![AssembledGroupInstance {
576                    segments: vec![AssembledSegment {
577                        tag: "NAD".to_string(),
578                        elements: vec![vec!["MS".to_string()]],
579                        mig_number: None,
580                        segment_number: None,
581                    }],
582                    child_groups: vec![AssembledGroup {
583                        group_id: "SG3".to_string(),
584                        repetitions: vec![AssembledGroupInstance {
585                            segments: vec![
586                                AssembledSegment {
587                                    tag: "CTA".to_string(),
588                                    elements: vec![vec!["IC".to_string()]],
589                                    mig_number: None,
590                                    segment_number: None,
591                                },
592                                AssembledSegment {
593                                    tag: "COM".to_string(),
594                                    elements: vec![vec![
595                                        "040@ex.com".to_string(),
596                                        "EM".to_string(),
597                                    ]],
598                                    mig_number: None,
599                                    segment_number: None,
600                                },
601                            ],
602                            child_groups: vec![],
603                            entry_mig_number: None,
604                            variant_mig_numbers: vec![],
605                            skipped_segments: vec![],
606                            skipped_positions: Vec::new(),
607                        }],
608                    }],
609                    entry_mig_number: None,
610                    variant_mig_numbers: vec![],
611                    skipped_segments: vec![],
612                    skipped_positions: Vec::new(),
613                }],
614            }],
615            inter_group_segments: std::collections::BTreeMap::new(),
616        };
617
618        let disassembler = Disassembler::new(&mig);
619        let segments = disassembler.disassemble(&tree);
620
621        assert_eq!(segments.len(), 4); // UNH, NAD, CTA, COM
622        assert_eq!(segments[0].tag, "UNH");
623        assert_eq!(segments[1].tag, "NAD");
624        assert_eq!(segments[2].tag, "CTA");
625        assert_eq!(segments[3].tag, "COM");
626    }
627
628    #[test]
629    fn test_disassemble_variant_groups_uses_per_variant_ordering() {
630        use crate::test_support::make_mig_group_with_variant;
631
632        // Two SG8 variant definitions with different segment ordering:
633        // - ZD7 variant: SEQ, RFF, DTM
634        // - Z98 variant: SEQ, CCI, CAV
635        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF", "DTM"], vec![], "ZD7");
636        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI", "CAV"], vec![], "Z98");
637
638        let mig = MigSchema {
639            message_type: "UTILMD".to_string(),
640            variant: None,
641            version: "S2.1".to_string(),
642            publication_date: "".to_string(),
643            author: "".to_string(),
644            format_version: "FV2504".to_string(),
645            source_file: "test".to_string(),
646            segments: vec![make_mig_segment("UNH")],
647            segment_groups: vec![sg8_zd7, sg8_z98],
648        };
649
650        // Assembled tree has 3 reps: ZD7, Z98, ZD7 (interleaved)
651        let tree = AssembledTree {
652            segments: vec![AssembledSegment {
653                tag: "UNH".to_string(),
654                elements: vec![vec!["1".to_string()]],
655                mig_number: None,
656                segment_number: None,
657            }],
658            post_group_start: 1,
659            groups: vec![AssembledGroup {
660                group_id: "SG8".to_string(),
661                repetitions: vec![
662                    // Rep 0: ZD7 variant — has SEQ, DTM, RFF (out of MIG order)
663                    AssembledGroupInstance {
664                        segments: vec![
665                            AssembledSegment {
666                                tag: "SEQ".to_string(),
667                                elements: vec![vec!["ZD7".to_string()]],
668                                mig_number: None,
669                                segment_number: None,
670                            },
671                            AssembledSegment {
672                                tag: "DTM".to_string(),
673                                elements: vec![vec!["303".to_string()]],
674                                mig_number: None,
675                                segment_number: None,
676                            },
677                            AssembledSegment {
678                                tag: "RFF".to_string(),
679                                elements: vec![vec!["Z13".to_string()]],
680                                mig_number: None,
681                                segment_number: None,
682                            },
683                        ],
684                        child_groups: vec![],
685                        entry_mig_number: None,
686                        variant_mig_numbers: vec![],
687                        skipped_segments: vec![],
688                        skipped_positions: Vec::new(),
689                    },
690                    // Rep 1: Z98 variant — has SEQ, CAV, CCI (out of MIG order)
691                    AssembledGroupInstance {
692                        segments: vec![
693                            AssembledSegment {
694                                tag: "SEQ".to_string(),
695                                elements: vec![vec!["Z98".to_string()]],
696                                mig_number: None,
697                                segment_number: None,
698                            },
699                            AssembledSegment {
700                                tag: "CAV".to_string(),
701                                elements: vec![vec!["Z91".to_string()]],
702                                mig_number: None,
703                                segment_number: None,
704                            },
705                            AssembledSegment {
706                                tag: "CCI".to_string(),
707                                elements: vec![vec!["".to_string()]],
708                                mig_number: None,
709                                segment_number: None,
710                            },
711                        ],
712                        child_groups: vec![],
713                        entry_mig_number: None,
714                        variant_mig_numbers: vec![],
715                        skipped_segments: vec![],
716                        skipped_positions: Vec::new(),
717                    },
718                    // Rep 2: another ZD7 variant
719                    AssembledGroupInstance {
720                        segments: vec![
721                            AssembledSegment {
722                                tag: "SEQ".to_string(),
723                                elements: vec![vec!["ZD7".to_string()]],
724                                mig_number: None,
725                                segment_number: None,
726                            },
727                            AssembledSegment {
728                                tag: "RFF".to_string(),
729                                elements: vec![vec!["Z34".to_string()]],
730                                mig_number: None,
731                                segment_number: None,
732                            },
733                        ],
734                        child_groups: vec![],
735                        entry_mig_number: None,
736                        variant_mig_numbers: vec![],
737                        skipped_segments: vec![],
738                        skipped_positions: Vec::new(),
739                    },
740                ],
741            }],
742            inter_group_segments: std::collections::BTreeMap::new(),
743        };
744
745        let disassembler = Disassembler::new(&mig);
746        let segments = disassembler.disassemble(&tree);
747
748        // UNH + 3 reps
749        assert_eq!(segments[0].tag, "UNH");
750
751        // Rep 0 (ZD7): MIG order is SEQ, RFF, DTM
752        assert_eq!(segments[1].tag, "SEQ");
753        assert_eq!(segments[1].elements[0][0], "ZD7");
754        assert_eq!(segments[2].tag, "RFF"); // reordered from position 3 to 2
755        assert_eq!(segments[3].tag, "DTM"); // reordered from position 2 to 3
756
757        // Rep 1 (Z98): MIG order is SEQ, CCI, CAV
758        assert_eq!(segments[4].tag, "SEQ");
759        assert_eq!(segments[4].elements[0][0], "Z98");
760        assert_eq!(segments[5].tag, "CCI"); // reordered from position 3 to 2
761        assert_eq!(segments[6].tag, "CAV"); // reordered from position 2 to 3
762
763        // Rep 2 (ZD7): SEQ, RFF (no DTM)
764        assert_eq!(segments[7].tag, "SEQ");
765        assert_eq!(segments[7].elements[0][0], "ZD7");
766        assert_eq!(segments[8].tag, "RFF");
767
768        assert_eq!(segments.len(), 9);
769    }
770
771    /// A repetition belongs to the variant whose *allowed* qualifier codes
772    /// (`variant_codes`, read at `variant_qualifier_position`) contain its entry
773    /// qualifier — not only to the variant whose first code (`variant_code`) it
774    /// happens to carry. Otherwise the rep is emitted with the wrong variant
775    /// definition and its nested groups, which only the correct variant
776    /// declares, are silently dropped (UTILMD 55013: SG9 `QTY` under `SEQ+Z98`,
777    /// whose SG8 variant allows `Z01, Z80, Z81, Z98`).
778    #[test]
779    fn test_disassemble_variant_group_matches_any_allowed_code() {
780        use crate::test_support::make_mig_group_with_variant;
781
782        let seg = |tag: &str, qual: &str| AssembledSegment {
783            tag: tag.to_string(),
784            elements: vec![vec![qual.to_string()]],
785            mig_number: None,
786            segment_number: None,
787        };
788        let instance = |segments: Vec<AssembledSegment>, child_groups: Vec<AssembledGroup>| {
789            AssembledGroupInstance {
790                segments,
791                child_groups,
792                entry_mig_number: None,
793                variant_mig_numbers: vec![],
794                skipped_segments: vec![],
795                skipped_positions: Vec::new(),
796            }
797        };
798        let group = |id: &str, reps: Vec<AssembledGroupInstance>| AssembledGroup {
799            group_id: id.to_string(),
800            repetitions: reps,
801        };
802
803        // Variant 1: SEQ+ZD7, no SG9. Variant 2: SEQ+{Z01,Z98} with SG9.
804        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "ZD7");
805        let mut sg8_z01 = make_mig_group_with_variant(
806            "SG8",
807            vec!["SEQ"],
808            vec![make_mig_group("SG9", vec!["QTY"], vec![])],
809            "Z01",
810        );
811        sg8_z01.variant_codes = vec!["Z01".to_string(), "Z98".to_string()];
812
813        let mig = MigSchema {
814            message_type: "UTILMD".to_string(),
815            variant: None,
816            version: "S2.1".to_string(),
817            publication_date: String::new(),
818            author: String::new(),
819            format_version: "FV2504".to_string(),
820            source_file: "test".to_string(),
821            segments: vec![],
822            segment_groups: vec![sg8_zd7, sg8_z01],
823        };
824
825        let tree = AssembledTree {
826            segments: vec![],
827            post_group_start: 0,
828            groups: vec![group(
829                "SG8",
830                vec![instance(
831                    vec![seg("SEQ", "Z98")],
832                    vec![group("SG9", vec![instance(vec![seg("QTY", "31")], vec![])])],
833                )],
834            )],
835            inter_group_segments: std::collections::BTreeMap::new(),
836        };
837
838        let tags: Vec<String> = Disassembler::new(&mig)
839            .disassemble(&tree)
840            .iter()
841            .map(|s| s.tag.clone())
842            .collect();
843        assert_eq!(tags, ["SEQ", "QTY"], "SG9 under SEQ+Z98 must survive");
844    }
845
846    #[test]
847    fn test_disassemble_empty_tree() {
848        let mig = MigSchema {
849            message_type: "UTILMD".to_string(),
850            variant: None,
851            version: "S2.1".to_string(),
852            publication_date: "".to_string(),
853            author: "".to_string(),
854            format_version: "FV2504".to_string(),
855            source_file: "test".to_string(),
856            segments: vec![make_mig_segment("UNH")],
857            segment_groups: vec![],
858        };
859
860        let tree = AssembledTree {
861            segments: vec![],
862            groups: vec![],
863            post_group_start: 0,
864            inter_group_segments: std::collections::BTreeMap::new(),
865        };
866
867        let disassembler = Disassembler::new(&mig);
868        let segments = disassembler.disassemble(&tree);
869        assert!(segments.is_empty());
870    }
871
872    fn seg(tag: &str, first: &str) -> AssembledSegment {
873        AssembledSegment {
874            tag: tag.to_string(),
875            elements: vec![vec![first.to_string()]],
876            mig_number: None,
877            segment_number: None,
878        }
879    }
880
881    fn instance(
882        segments: Vec<AssembledSegment>,
883        child_groups: Vec<AssembledGroup>,
884    ) -> AssembledGroupInstance {
885        AssembledGroupInstance {
886            segments,
887            child_groups,
888            entry_mig_number: None,
889            variant_mig_numbers: vec![],
890            skipped_segments: vec![],
891            skipped_positions: vec![],
892        }
893    }
894
895    /// UNH, SG4[IDE] > SG8_Z03[SEQ] > SG10[CCI, CAV] — the #103 shape.
896    fn sg10_mig_and_tree(sg10: AssembledGroupInstance) -> (MigSchema, AssembledTree) {
897        let sg10_mig = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
898        let sg8_mig = crate::test_support::make_mig_group_with_variant(
899            "SG8",
900            vec!["SEQ"],
901            vec![sg10_mig],
902            "Z03",
903        );
904        let sg4_mig = make_mig_group("SG4", vec!["IDE"], vec![sg8_mig]);
905        let mig = MigSchema {
906            message_type: "UTILMD".to_string(),
907            variant: None,
908            version: "S2.1".to_string(),
909            publication_date: "".to_string(),
910            author: "".to_string(),
911            format_version: "FV2604".to_string(),
912            source_file: "test".to_string(),
913            segments: vec![make_mig_segment("UNH")],
914            segment_groups: vec![sg4_mig],
915        };
916        let sg8 = instance(
917            vec![seg("SEQ", "Z03")],
918            vec![AssembledGroup {
919                group_id: "SG10".to_string(),
920                repetitions: vec![sg10],
921            }],
922        );
923        let sg4 = instance(
924            vec![seg("IDE", "24")],
925            vec![AssembledGroup {
926                group_id: "SG8".to_string(),
927                repetitions: vec![sg8],
928            }],
929        );
930        let tree = AssembledTree {
931            segments: vec![seg("UNH", "1")],
932            groups: vec![AssembledGroup {
933                group_id: "SG4".to_string(),
934                repetitions: vec![sg4],
935            }],
936            post_group_start: 1,
937            inter_group_segments: std::collections::BTreeMap::new(),
938        };
939        (mig, tree)
940    }
941
942    /// UNH, SG5[IDE, STS, FTX, RFF], the instance as the reverse mapping
943    /// builds it: segments grouped per mapping rule, not in MIG order.
944    fn sg5_tags(segments: Vec<AssembledSegment>) -> Vec<String> {
945        let mig = MigSchema {
946            message_type: "UTILTS".to_string(),
947            variant: None,
948            version: "1.1e".to_string(),
949            publication_date: "".to_string(),
950            author: "".to_string(),
951            format_version: "FV2504".to_string(),
952            source_file: "test".to_string(),
953            segments: vec![make_mig_segment("UNH")],
954            segment_groups: vec![make_mig_group(
955                "SG5",
956                vec!["IDE", "STS", "FTX", "RFF", "RFF"],
957                vec![],
958            )],
959        };
960        let tree = AssembledTree {
961            segments: vec![seg("UNH", "1")],
962            groups: vec![AssembledGroup {
963                group_id: "SG5".to_string(),
964                repetitions: vec![instance(segments, vec![])],
965            }],
966            post_group_start: 1,
967            inter_group_segments: std::collections::BTreeMap::new(),
968        };
969        Disassembler::new(&mig)
970            .disassemble(&tree)
971            .into_iter()
972            .map(|s| format!("{}+{}", s.tag, s.elements[0][0]))
973            .collect()
974    }
975
976    #[test]
977    fn repeated_segment_stays_at_its_mig_slot() {
978        let tags = sg5_tags(vec![
979            seg("FTX", "1"),
980            seg("FTX", "2"),
981            seg("IDE", "24"),
982            seg("RFF", "A"),
983            seg("RFF", "B"),
984            seg("RFF", "C"),
985            seg("STS", "1"),
986            seg("STS", "2"),
987        ]);
988        assert_eq!(
989            tags,
990            ["UNH+1", "IDE+24", "STS+1", "STS+2", "FTX+1", "FTX+2", "RFF+A", "RFF+B", "RFF+C"]
991        );
992    }
993
994    #[test]
995    fn repeated_entry_segment_is_not_pulled_into_the_entry_slot() {
996        // A second IDE is not a repetition of the entry slot (it would start
997        // the next group repetition); it keeps going to the end.
998        let tags = sg5_tags(vec![seg("IDE", "1"), seg("IDE", "2"), seg("STS", "1")]);
999        assert_eq!(tags, ["UNH+1", "IDE+1", "STS+1", "IDE+2"]);
1000    }
1001
1002    #[test]
1003    fn checked_disassembly_refuses_group_without_entry_segment() {
1004        let (mig, tree) = sg10_mig_and_tree(instance(vec![seg("CAV", "Z30")], vec![]));
1005        let disassembler = Disassembler::new(&mig);
1006
1007        // Lenient disassembly still emits it (tooling, roundtrip of raw trees).
1008        let tags: Vec<_> = disassembler
1009            .disassemble(&tree)
1010            .into_iter()
1011            .map(|s| s.tag)
1012            .collect();
1013        assert_eq!(tags, ["UNH", "IDE", "SEQ", "CAV"]);
1014
1015        let expected = MissingEntrySegment {
1016            group_path: "SG4.SG8.SG10".to_string(),
1017            source_path: "sg4.sg8_z03.sg10".to_string(),
1018            entry_segment: "CCI".to_string(),
1019            present_segments: vec!["CAV".to_string()],
1020        };
1021        assert_eq!(
1022            disassembler.missing_entry_segments(&tree),
1023            vec![expected.clone()]
1024        );
1025        match disassembler.disassemble_checked(&tree) {
1026            Err(AssemblyError::MissingGroupEntrySegment {
1027                group_path,
1028                source_path,
1029                entry_segment,
1030                present_segments,
1031            }) => {
1032                assert_eq!(group_path, expected.group_path);
1033                assert_eq!(source_path, expected.source_path);
1034                assert_eq!(entry_segment, expected.entry_segment);
1035                assert_eq!(present_segments, expected.present_segments);
1036            }
1037            other => panic!("expected MissingGroupEntrySegment, got {other:?}"),
1038        }
1039    }
1040
1041    #[test]
1042    fn checked_disassembly_accepts_complete_groups() {
1043        let (mig, tree) =
1044            sg10_mig_and_tree(instance(vec![seg("CCI", ""), seg("CAV", "Z30")], vec![]));
1045        let disassembler = Disassembler::new(&mig);
1046        assert!(disassembler.missing_entry_segments(&tree).is_empty());
1047        let tags: Vec<_> = disassembler
1048            .disassemble_checked(&tree)
1049            .expect("complete tree")
1050            .into_iter()
1051            .map(|s| s.tag)
1052            .collect();
1053        assert_eq!(tags, ["UNH", "IDE", "SEQ", "CCI", "CAV"]);
1054    }
1055
1056    #[test]
1057    fn empty_group_instance_is_not_a_missing_entry_segment() {
1058        let (mig, tree) = sg10_mig_and_tree(instance(vec![], vec![]));
1059        assert!(Disassembler::new(&mig)
1060            .missing_entry_segments(&tree)
1061            .is_empty());
1062    }
1063}