Skip to main content

mig_assembly/
assembler.rs

1//! Recursive descent assembler — MIG-guided segment consumption.
2//!
3//! The assembler walks the MIG tree structure and consumes matching
4//! segments from the input. It produces a generic tree representation
5//! that can be converted to typed PID structs.
6
7use crate::cursor::SegmentCursor;
8use crate::diagnostic::{StructureDiagnostic, StructureDiagnosticKind};
9use crate::matcher;
10use crate::tokenize::OwnedSegment;
11use crate::AssemblyError;
12use mig_types::schema::mig::{MigSchema, MigSegment, MigSegmentGroup};
13use serde::{Deserialize, Serialize};
14
15/// A generic assembled tree node (before PID-specific typing).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AssembledTree {
18    pub segments: Vec<AssembledSegment>,
19    pub groups: Vec<AssembledGroup>,
20    /// Index in `segments` where post-group segments start (e.g., UNT, UNZ).
21    /// Segments before this index appear before groups in EDIFACT order.
22    #[serde(default)]
23    pub post_group_start: usize,
24    /// Root segments consumed between groups during assembly (e.g., UNS
25    /// section separator in MSCONS). Key = index into `groups` vec; value =
26    /// segments that appear immediately before that group in the EDIFACT
27    /// stream. Empty for messages without inter-group root segments.
28    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
29    pub inter_group_segments: std::collections::BTreeMap<usize, Vec<AssembledSegment>>,
30}
31
32/// An assembled segment with its data elements.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct AssembledSegment {
35    pub tag: String,
36    /// `elements[i][j]` = component `j` of element `i`
37    pub elements: Vec<Vec<String>>,
38    /// MIG `Number` attribute identifying this segment variant.
39    /// Two segments with the same tag (e.g., DTM) but different roles
40    /// (DTM+92 vs DTM+93) have distinct MIG numbers.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub mig_number: Option<String>,
43    /// Source counter position of this segment within the input message,
44    /// preserved so AHB-validator-emitted issues can populate UCS `0096`
45    /// (segmentPosition) in CONTRL responses. `None` when the segment was
46    /// constructed by the reverse mapper (BO4E -> EDIFACT) since it has no
47    /// source position.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub segment_number: Option<u32>,
50}
51
52/// An assembled segment group (may repeat).
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct AssembledGroup {
55    pub group_id: String,
56    pub repetitions: Vec<AssembledGroupInstance>,
57}
58
59/// One repetition of a segment group.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct AssembledGroupInstance {
62    pub segments: Vec<AssembledSegment>,
63    pub child_groups: Vec<AssembledGroup>,
64    /// MIG `Number` of the entry segment that identified this group instance's variant.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub entry_mig_number: Option<String>,
67    /// All MIG `Number`s defined for this group variant — includes segments that
68    /// may be absent in the EDIFACT but are defined in the MIG for this variant.
69    ///
70    /// Used by the validator to determine which AHB rules belong to this instance:
71    /// a rule with `mig_number` in this set applies here, even if the segment is
72    /// missing (which is then a missing-field error). Without this, rules for
73    /// absent-but-required segments would be incorrectly filtered out.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub variant_mig_numbers: Vec<String>,
76    /// Segments that were present in the EDIFACT input but not defined in
77    /// the PID-filtered MIG for this group. Only populated when the assembler
78    /// runs with [`AssemblerConfig::skip_unknown_segments`] enabled.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub skipped_segments: Vec<AssembledSegment>,
81    /// Input positions of each skipped segment, parallel to `skipped_segments`.
82    /// Used by `assemble_with_diagnostics` to locate AHB-foreign content in
83    /// the original EDIFACT stream for STR008 diagnostics.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub skipped_positions: Vec<usize>,
86}
87
88impl AssembledGroupInstance {
89    /// Create a virtual `AssembledTree` scoped to this group instance.
90    ///
91    /// The instance's own segments become the tree's root segments,
92    /// and its child groups become the tree's groups. This enables
93    /// running `MappingEngine::map_all_forward()` on a single
94    /// transaction group as if it were a complete message.
95    pub fn as_assembled_tree(&self) -> AssembledTree {
96        AssembledTree {
97            segments: self.segments.clone(),
98            groups: self.child_groups.clone(),
99            post_group_start: self.segments.len(),
100            inter_group_segments: std::collections::BTreeMap::new(),
101        }
102    }
103}
104
105/// Configuration for the assembler.
106#[derive(Debug, Clone, Default)]
107pub struct AssemblerConfig {
108    /// When `true`, the assembler skips segments inside a group instance that
109    /// don't match any remaining MIG slot, nested-group entry, or the group's
110    /// entry tag (next repetition). Skipped segments are preserved on
111    /// [`AssembledGroupInstance::skipped_segments`] for roundtrip re-emission.
112    ///
113    /// Default: `false` (strict AHB — unknown segments stall the cursor).
114    pub skip_unknown_segments: bool,
115
116    /// Qualifier-aware assembly: maps MIG `Number` to `(element_index, component_index, expected_value)`.
117    ///
118    /// When a bounded slot has a `number` with an entry in this map,
119    /// `try_consume_segment` checks the input segment's value at the
120    /// specified position. If it doesn't match, the slot is skipped (segment
121    /// is for a different qualifier variant).
122    ///
123    /// Build from the PID schema JSON, or construct manually:
124    /// `{ "00023" => (0, 0, "92".to_string()), "00024" => (0, 0, "93".to_string()) }`.
125    ///
126    /// Default: empty (positional assembly, no qualifier checking).
127    pub qualifier_map: std::collections::HashMap<String, (usize, usize, String)>,
128
129    /// Reject a slot when the input segment has a value outside the slot's
130    /// allowed codes at any code-bearing position (not just the primary
131    /// qualifier). Disambiguates PID-filtered slots that share a primary
132    /// qualifier but differ on a secondary code (e.g. PID 55035 SG8/PIA
133    /// variants all use 4347='5' but differ at C212/7143).
134    ///
135    /// Default: `false`. Only safe to enable on a PID-filtered MIG whose
136    /// per-slot codes reflect the AHB-narrowed allowed sets — on the raw
137    /// MIG, each slot's codes cover only one variant and strict matching
138    /// would leave most segments unconsumed.
139    pub strict_code_matching: bool,
140}
141
142/// MIG-guided assembler.
143///
144/// Takes a MIG schema and uses it as a grammar to guide consumption
145/// of parsed EDIFACT segments. Produces a generic `AssembledTree`.
146pub struct Assembler<'a> {
147    mig: &'a MigSchema,
148    config: AssemblerConfig,
149}
150
151impl<'a> Assembler<'a> {
152    pub fn new(mig: &'a MigSchema) -> Self {
153        Self {
154            mig,
155            config: AssemblerConfig::default(),
156        }
157    }
158
159    pub fn with_config(mig: &'a MigSchema, config: AssemblerConfig) -> Self {
160        Self { mig, config }
161    }
162
163    /// Tags reachable at the top level from a group's point of view — used
164    /// as the initial enclosing scope for the skip-unknown retry logic.
165    ///
166    /// Excludes top-level segment slots that have already been matched
167    /// (they won't be consumed again) and top-level group entries that
168    /// come before the current group (those groups have already run).
169    /// What's left is: unmatched top-level segments (e.g., UNT/UNZ still
170    /// to come) and top-level group entries of siblings the assembler
171    /// hasn't reached yet. A group's retry loop should break on those
172    /// tags (structural for an upcoming step) but may skip anything else
173    /// that slipped past the earlier passes.
174    ///
175    /// Empty when skip mode is off (the skip path is never taken).
176    fn top_level_enclosing_for_group(
177        &self,
178        current_group_idx: usize,
179        matched_seg_indices: &[usize],
180    ) -> std::collections::HashSet<String> {
181        if !self.config.skip_unknown_segments {
182            return std::collections::HashSet::new();
183        }
184        let mut tags: std::collections::HashSet<String> = self
185            .mig
186            .segments
187            .iter()
188            .enumerate()
189            .filter_map(|(i, s)| {
190                if matched_seg_indices.contains(&i) {
191                    None
192                } else {
193                    Some(s.id.clone())
194                }
195            })
196            .collect();
197        for (idx, group) in self.mig.segment_groups.iter().enumerate() {
198            if idx < current_group_idx {
199                continue;
200            }
201            if let Some(entry) = group.segments.first() {
202                tags.insert(entry.id.clone());
203            }
204        }
205        tags
206    }
207
208    /// Assemble segments into a generic tree following MIG structure.
209    pub fn assemble_generic(
210        &self,
211        segments: &[OwnedSegment],
212    ) -> Result<AssembledTree, AssemblyError> {
213        let mut cursor = SegmentCursor::new(segments.len());
214        let mut tree = AssembledTree {
215            segments: Vec::new(),
216            groups: Vec::new(),
217            post_group_start: 0,
218            inter_group_segments: std::collections::BTreeMap::new(),
219        };
220
221        // Track which MIG segment indices were matched in the first pass
222        let mut matched_seg_indices = Vec::new();
223
224        // Process top-level segments (first pass — before groups)
225        for (i, mig_seg) in self.mig.segments.iter().enumerate() {
226            if cursor.is_exhausted() {
227                break;
228            }
229            if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
230                tree.segments.push(assembled);
231                matched_seg_indices.push(i);
232            }
233        }
234
235        // Process segment groups, interleaving root segment consumption.
236        // Some message types (e.g., MSCONS) have root segments like UNS
237        // between groups (SG2 and SG5). Before trying each group, consume
238        // any unmatched root segments at the current cursor position.
239        //
240        // When consecutive same-ID groups have variant_code set (e.g., 3 SG8
241        // entries for ZD7, Z98, ZF3), the assembler tries ALL variants at each
242        // cursor position to handle interleaved reps.
243        let mut group_idx = 0;
244        while group_idx < self.mig.segment_groups.len() {
245            if cursor.is_exhausted() {
246                break;
247            }
248
249            let mig_group = &self.mig.segment_groups[group_idx];
250
251            // Top-level skip-unknown: in skip mode, advance the cursor past
252            // any input segment whose tag is neither an unmatched root MIG
253            // segment nor any group entry. Without this, an AHB-foreign
254            // top-level segment (e.g. IMD in QUOTES PID 15005) stalls the
255            // assembler before the first group entry is reached.
256            //
257            // Skipped segments are stashed in `inter_group_segments[N]` so
258            // disassembly re-emits them at the same logical position.
259            if self.config.skip_unknown_segments {
260                let tree_group_idx = tree.groups.len();
261                while !cursor.is_exhausted() {
262                    let seg = &segments[cursor.position()];
263                    let tag = &seg.id;
264                    let is_unmatched_root_seg = self
265                        .mig
266                        .segments
267                        .iter()
268                        .enumerate()
269                        .any(|(i, ms)| !matched_seg_indices.contains(&i) && ms.id == *tag);
270                    let is_any_group_entry = self
271                        .mig
272                        .segment_groups
273                        .iter()
274                        .any(|g| g.segments.first().is_some_and(|s| s.id == *tag));
275                    if is_unmatched_root_seg || is_any_group_entry {
276                        break;
277                    }
278                    tree.inter_group_segments
279                        .entry(tree_group_idx)
280                        .or_default()
281                        .push(owned_to_assembled(seg));
282                    cursor.advance();
283                }
284                if cursor.is_exhausted() {
285                    break;
286                }
287            }
288
289            // Try consuming unmatched root segments before this group
290            let tree_group_idx = tree.groups.len();
291            for (i, mig_seg) in self.mig.segments.iter().enumerate() {
292                if cursor.is_exhausted() {
293                    break;
294                }
295                if matched_seg_indices.contains(&i) {
296                    continue;
297                }
298                if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
299                    tree.inter_group_segments
300                        .entry(tree_group_idx)
301                        .or_default()
302                        .push(assembled);
303                    matched_seg_indices.push(i);
304                }
305            }
306
307            // Scope visible to this group's skip-retry logic: top-level
308            // segments still to be consumed, plus entries of sibling
309            // top-level groups that haven't run yet. Built per-group so
310            // already-matched DTM/BGM slots don't incorrectly protect an
311            // AHB-foreign DTM+92 inside SG4 from being skipped.
312            let top_enclosing = self.top_level_enclosing_for_group(group_idx, &matched_seg_indices);
313
314            // Check if this starts a variant set (consecutive same-ID groups with variant_code)
315            if mig_group.variant_code.is_some() {
316                let variant_count = self.mig.segment_groups[group_idx..]
317                    .iter()
318                    .take_while(|g| g.id == mig_group.id && g.variant_code.is_some())
319                    .count();
320                let variant_end = group_idx + variant_count;
321
322                let variant_groups = &self.mig.segment_groups[group_idx..variant_end];
323                if let Some(combined) = self.try_consume_variant_groups(
324                    segments,
325                    &mut cursor,
326                    variant_groups,
327                    &top_enclosing,
328                )? {
329                    tree.groups.push(combined);
330                }
331                group_idx = variant_end;
332            } else {
333                if let Some(assembled) =
334                    self.try_consume_group(segments, &mut cursor, mig_group, &top_enclosing)?
335                {
336                    tree.groups.push(assembled);
337                }
338                group_idx += 1;
339            }
340        }
341
342        // Mark where post-group segments start
343        tree.post_group_start = tree.segments.len();
344
345        // Second pass: try unmatched top-level segments (e.g., UNT, UNZ after groups)
346        for (i, mig_seg) in self.mig.segments.iter().enumerate() {
347            if cursor.is_exhausted() {
348                break;
349            }
350            if matched_seg_indices.contains(&i) {
351                continue;
352            }
353            if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
354                tree.segments.push(assembled);
355            }
356        }
357
358        Ok(tree)
359    }
360
361    fn try_consume_segment(
362        &self,
363        segments: &[OwnedSegment],
364        cursor: &mut SegmentCursor,
365        mig_seg: &MigSegment,
366    ) -> Result<Option<AssembledSegment>, AssemblyError> {
367        if cursor.is_exhausted() {
368            return Ok(None);
369        }
370        let seg = &segments[cursor.position()];
371        if matcher::matches_segment_tag(&seg.id, &mig_seg.id) {
372            // Qualifier check: if the MIG slot has a qualifier_map entry,
373            // verify the input segment's qualifier matches before consuming.
374            if let Some(ref num) = mig_seg.number {
375                if let Some((el_idx, comp_idx, expected)) = self.config.qualifier_map.get(num) {
376                    let actual = seg
377                        .elements
378                        .get(*el_idx)
379                        .and_then(|e| e.get(*comp_idx))
380                        .map(|s| s.as_str())
381                        .unwrap_or("");
382                    if actual != expected {
383                        return Ok(None); // Wrong qualifier — skip this slot
384                    }
385                }
386            }
387            // Note: full-code-profile matching (for disambiguating merged
388            // sibling slots sharing a primary qualifier, e.g. PID 55035 PIA
389            // variants all use 4347='5' but differ at C212/7143) is handled
390            // by the caller in `try_consume_group`'s entry-run when strict
391            // mode is on and `run_len > 1`. Doing it here would reject
392            // solo-slot segments whose codes fall outside the AHB-narrowed
393            // allowed set — those belong to the validator as COD002.
394            let mut assembled = owned_to_assembled(seg);
395            assembled.mig_number = mig_seg.number.clone();
396            cursor.advance();
397            Ok(Some(assembled))
398        } else {
399            Ok(None) // Segment not present (optional)
400        }
401    }
402
403    /// Consume the entry run of a group with best-match slot selection.
404    ///
405    /// Used when `strict_code_matching` is on and `run_len > 1` — the group
406    /// has multiple sibling entry slots with the same tag (merged PID-specific
407    /// variants). For each pending segment, picks the unused slot whose full
408    /// code profile matches best. Ties broken by MIG order. When no slot's
409    /// profile matches, falls back to the first unused tag+qualifier-matching
410    /// slot so the segment is still consumed (the validator emits COD002 if
411    /// the code is truly invalid).
412    ///
413    /// With `repeat` (a run of non-entry slots), a slot takes further segments
414    /// whose codes match it, up to its `MaxRep_Specification`: UTILMD's Wandler
415    /// SG10 gives CAV+Z30 (up to 3) then CAV+MIW, and a second Gerätenummer must
416    /// not take the MIW slot. An entry run keeps one segment per slot — a
417    /// repeated entry segment starts the next group repetition.
418    fn consume_entry_run_best_match(
419        &self,
420        segments: &[OwnedSegment],
421        cursor: &mut SegmentCursor,
422        entry_slots: &[MigSegment],
423        repeat: bool,
424        instance: &mut AssembledGroupInstance,
425    ) -> Result<(), AssemblyError> {
426        let capacity = |slot: &MigSegment| {
427            if repeat {
428                slot.max_rep_spec.max(1) as usize
429            } else {
430                1
431            }
432        };
433        let mut used = vec![0usize; entry_slots.len()];
434        let total: usize = entry_slots.iter().map(capacity).sum();
435        for _ in 0..total {
436            if cursor.is_exhausted() {
437                break;
438            }
439            let seg = &segments[cursor.position()];
440            let mut strict_match: Option<usize> = None;
441            let mut tag_match: Option<usize> = None;
442            for (i, slot) in entry_slots.iter().enumerate() {
443                if used[i] >= capacity(slot) {
444                    continue;
445                }
446                if !matcher::matches_segment_tag(&seg.id, &slot.id) {
447                    continue;
448                }
449                if !self.segment_passes_qualifier_map(seg, slot) {
450                    continue;
451                }
452                // Only a code match repeats a slot; the fallback takes a fresh one.
453                if tag_match.is_none() && used[i] == 0 {
454                    tag_match = Some(i);
455                }
456                if strict_match.is_none() && segment_matches_mig_codes(seg, slot) {
457                    strict_match = Some(i);
458                }
459            }
460            let Some(i) = strict_match.or(tag_match) else {
461                break;
462            };
463            used[i] += 1;
464            let slot = &entry_slots[i];
465            let mut assembled = owned_to_assembled(seg);
466            assembled.mig_number = slot.number.clone();
467            instance.segments.push(assembled);
468            cursor.advance();
469        }
470        Ok(())
471    }
472
473    fn segment_passes_qualifier_map(&self, seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
474        let Some(ref num) = mig_seg.number else {
475            return true;
476        };
477        let Some((el_idx, comp_idx, expected)) = self.config.qualifier_map.get(num) else {
478            return true;
479        };
480        let actual = seg
481            .elements
482            .get(*el_idx)
483            .and_then(|e| e.get(*comp_idx))
484            .map(|s| s.as_str())
485            .unwrap_or("");
486        actual == expected
487    }
488
489    fn try_consume_group(
490        &self,
491        segments: &[OwnedSegment],
492        cursor: &mut SegmentCursor,
493        mig_group: &MigSegmentGroup,
494        enclosing: &std::collections::HashSet<String>,
495    ) -> Result<Option<AssembledGroup>, AssemblyError> {
496        let mut repetitions = Vec::new();
497        let entry_segment = mig_group.segments.first().ok_or_else(|| {
498            AssemblyError::ParseError(format!("Group {} has no segments", mig_group.id))
499        })?;
500
501        // Scope visible to skip decisions inside this group: our own local
502        // scope (entry + slots + direct nested entries) unioned with the
503        // caller's enclosing scope. Only built when skip mode is on —
504        // otherwise the skip path is dead code and the set stays unused.
505        let nested_enclosing: std::collections::HashSet<String> =
506            if self.config.skip_unknown_segments {
507                let mut set = enclosing.clone();
508                set.extend(group_local_scope(mig_group));
509                set
510            } else {
511                std::collections::HashSet::new()
512            };
513
514        // Loop for repeating groups
515        while !cursor.is_exhausted() {
516            let iter_start = cursor.position();
517            let seg = &segments[cursor.position()];
518            if !matcher::matches_segment_tag(&seg.id, &entry_segment.id) {
519                break; // Current segment doesn't match group entry — stop repeating
520            }
521
522            // Check variant qualifier if set — tag matches but wrong variant
523            if !mig_group.variant_codes.is_empty() {
524                let (ei, ci) = mig_group.variant_qualifier_position.unwrap_or((0, 0));
525                let actual_qual = seg
526                    .elements
527                    .get(ei)
528                    .and_then(|e| e.get(ci))
529                    .map(|s| s.as_str())
530                    .unwrap_or("");
531                if !mig_group
532                    .variant_codes
533                    .iter()
534                    .any(|c| actual_qual.eq_ignore_ascii_case(c))
535                {
536                    break;
537                }
538            } else if let Some(ref expected_code) = mig_group.variant_code {
539                let (ei, ci) = mig_group.variant_qualifier_position.unwrap_or((0, 0));
540                let actual_qual = seg
541                    .elements
542                    .get(ei)
543                    .and_then(|e| e.get(ci))
544                    .map(|s| s.as_str())
545                    .unwrap_or("");
546                if !actual_qual.eq_ignore_ascii_case(expected_code) {
547                    break;
548                }
549            }
550
551            let mut instance = AssembledGroupInstance {
552                segments: Vec::new(),
553                child_groups: Vec::new(),
554                entry_mig_number: entry_segment.number.clone(),
555                variant_mig_numbers: collect_mig_numbers(mig_group),
556                skipped_segments: Vec::new(),
557                skipped_positions: Vec::new(),
558            };
559
560            // Consume segments within this group instance.
561            // Process MIG slots in tag runs: for consecutive slots with the
562            // same tag, consume ALL matching input segments — not just the
563            // defined count. This handles real-world fixtures with more
564            // repetitions than the merged MIG predicts (e.g., 6 RFFs when
565            // the schema defines max 4).
566            //
567            // The entry segment (first tag run) is consumed bounded — one per
568            // defined slot — because the outer while loop uses the entry tag
569            // to delineate group repetitions.
570            let mut slot_idx = 0;
571            let mut is_entry_run = true;
572            while slot_idx < mig_group.segments.len() {
573                if cursor.is_exhausted() {
574                    break;
575                }
576                let current_tag = &mig_group.segments[slot_idx].id;
577                let run_len = mig_group.segments[slot_idx..]
578                    .iter()
579                    .take_while(|s| s.id == *current_tag)
580                    .count();
581
582                if is_entry_run {
583                    // Entry tag: consume at most run_len (preserves group boundaries)
584                    let entry_slots = &mig_group.segments[slot_idx..slot_idx + run_len];
585                    if self.config.strict_code_matching && run_len > 1 {
586                        // Best-match: among tag-matching sibling slots, prefer
587                        // the one whose full code profile matches the segment
588                        // (disambiguates PID 55035 PIA 00108/Z12 vs 00197/SRW).
589                        // Falls back to MIG order when no profile matches —
590                        // preserves assembly for codes outside any AHB-narrowed set.
591                        self.consume_entry_run_best_match(
592                            segments,
593                            cursor,
594                            entry_slots,
595                            false,
596                            &mut instance,
597                        )?;
598                    } else {
599                        for slot in entry_slots {
600                            if cursor.is_exhausted() {
601                                break;
602                            }
603                            if let Some(assembled) =
604                                self.try_consume_segment(segments, cursor, slot)?
605                            {
606                                instance.segments.push(assembled);
607                            }
608                        }
609                    }
610                    is_entry_run = false;
611                } else if matcher::matches_segment_tag(current_tag, &entry_segment.id) {
612                    // Non-entry slot with SAME tag as entry (e.g., CCI appears as
613                    // both entry and non-entry in merged SG30).
614                    //
615                    // Only consume if we haven't yet consumed any NON-entry-tag
616                    // segments (i.e., we're still in a consecutive entry-tag run).
617                    // Once we've consumed a different tag (like CAV), seeing the
618                    // entry tag again means a new rep boundary.
619                    //
620                    // z35: entry CCI → CAV CAV → sees CCI → has_other=true → break ✓
621                    // z39: entry CCI → (no CAV) → sees CCI → has_other=false → consume ✓
622                    //      then CCI CCI → CAV → sees CCI → has_other=true → break
623                    //      BUT: z39 needs CCI-CAV-CCI-CAV structure
624                    //
625                    // Better heuristic: check if ALL remaining slots from here are
626                    // entry-tag + non-entry pairs. If the current slot is entry-tag
627                    // and the NEXT input segment after it would be a non-entry tag,
628                    // consume — it's a continuation. Otherwise break.
629                    if cursor.is_exhausted() {
630                        break;
631                    }
632                    let seg = &segments[cursor.position()];
633                    if !matcher::matches_segment_tag(&seg.id, current_tag) {
634                        break;
635                    }
636                    // Check: is there a non-entry segment AFTER this entry-tag?
637                    // If so, this CCI+CAV pair is part of the current rep.
638                    let has_following_non_entry = if cursor.position() + 1 < segments.len() {
639                        let next = &segments[cursor.position() + 1];
640                        !matcher::matches_segment_tag(&next.id, &entry_segment.id)
641                            && mig_group.segments.iter().any(|s| {
642                                matcher::matches_segment_tag(&next.id, &s.id)
643                                    && !matcher::matches_segment_tag(&s.id, &entry_segment.id)
644                            })
645                    } else {
646                        false
647                    };
648                    if has_following_non_entry {
649                        // CCI followed by CAV → consume as continuation pair
650                        instance.segments.push(owned_to_assembled(seg));
651                        cursor.advance();
652                    } else {
653                        // CCI followed by CCI or unknown → let outer loop decide
654                        break;
655                    }
656                } else {
657                    // Non-entry tag: consume bounded slots first (with mig_number),
658                    // then greedily consume extras (without mig_number).
659                    // The bounded slots get mig_number from the MIG definition so
660                    // the validator can distinguish same-tag segments (e.g., DTM+92
661                    // vs DTM+93 both in SG4).
662                    let slots = &mig_group.segments[slot_idx..slot_idx + run_len];
663                    if self.config.strict_code_matching && run_len > 1 {
664                        self.consume_entry_run_best_match(
665                            segments,
666                            cursor,
667                            slots,
668                            true,
669                            &mut instance,
670                        )?;
671                    } else {
672                        for slot in slots {
673                            if cursor.is_exhausted() {
674                                break;
675                            }
676                            if let Some(assembled) =
677                                self.try_consume_segment(segments, cursor, slot)?
678                            {
679                                instance.segments.push(assembled);
680                            }
681                        }
682                    }
683                    // Greedily consume any remaining same-tag segments beyond the MIG count
684                    while !cursor.is_exhausted() {
685                        let seg = &segments[cursor.position()];
686                        if matcher::matches_segment_tag(&seg.id, current_tag) {
687                            instance.segments.push(owned_to_assembled(seg));
688                            cursor.advance();
689                        } else {
690                            break;
691                        }
692                    }
693                }
694
695                slot_idx += run_len;
696
697                // Point A: Skip unknown segments between MIG slot runs.
698                // When skip mode is ON and we just finished a slot run but the
699                // current segment doesn't match any remaining MIG slot, nested
700                // group entry, or the entry tag, skip it — unless the tag
701                // appears elsewhere in the full MIG (it belongs to an
702                // enclosing group, not this one).
703                if self.config.skip_unknown_segments {
704                    while !cursor.is_exhausted() {
705                        let seg = &segments[cursor.position()];
706                        // Stop if it matches the entry tag (next group repetition)
707                        if matcher::matches_segment_tag(&seg.id, &entry_segment.id) {
708                            break;
709                        }
710                        // Stop if it matches any remaining MIG slot
711                        if mig_group.segments[slot_idx..]
712                            .iter()
713                            .any(|s| matcher::matches_segment_tag(&seg.id, &s.id))
714                        {
715                            break;
716                        }
717                        // Stop if it matches any nested group entry
718                        if mig_group.nested_groups.iter().any(|ng| {
719                            ng.segments
720                                .first()
721                                .is_some_and(|es| matcher::matches_segment_tag(&seg.id, &es.id))
722                        }) {
723                            break;
724                        }
725                        // Stop if the tag is reachable from the enclosing
726                        // scope — it's structural for an outer group (e.g.,
727                        // an SG8 SEQ seen from inside SG10) and should
728                        // surface to the outer loop rather than be
729                        // swallowed here.
730                        if enclosing.contains(&seg.id) {
731                            break;
732                        }
733                        // Unknown segment — skip it
734                        instance.skipped_positions.push(cursor.position());
735                        instance.skipped_segments.push(owned_to_assembled(seg));
736                        cursor.advance();
737                    }
738                }
739            }
740
741            // Consume nested groups (variant-aware for same-ID groups).
742            //
743            // When `skip_unknown_segments` is on, the loop retries after
744            // stalling on an AHB-foreign segment that matches none of the
745            // nested-group entries — the orphan is recorded in
746            // `instance.skipped_segments` and subsequent legitimate reps are
747            // still assembled (PID 55035 cascade fix). Retries merge new
748            // child groups into the existing entry for the same id so a
749            // variant set assembled across two passes stays a single child.
750            loop {
751                let pass_start = cursor.position();
752                let mut nested_idx = 0;
753                while nested_idx < mig_group.nested_groups.len() {
754                    if cursor.is_exhausted() {
755                        break;
756                    }
757                    let nested = &mig_group.nested_groups[nested_idx];
758
759                    if nested.variant_code.is_some() {
760                        // Variant set: collect consecutive same-ID groups with variant_code
761                        let variant_count = mig_group.nested_groups[nested_idx..]
762                            .iter()
763                            .take_while(|g| g.id == nested.id && g.variant_code.is_some())
764                            .count();
765                        let variant_end = nested_idx + variant_count;
766                        let variant_groups = &mig_group.nested_groups[nested_idx..variant_end];
767                        if let Some(combined) = self.try_consume_variant_groups(
768                            segments,
769                            cursor,
770                            variant_groups,
771                            &nested_enclosing,
772                        )? {
773                            push_or_merge_child(&mut instance.child_groups, combined);
774                        }
775                        nested_idx = variant_end;
776                    } else {
777                        if let Some(assembled) =
778                            self.try_consume_group(segments, cursor, nested, &nested_enclosing)?
779                        {
780                            push_or_merge_child(&mut instance.child_groups, assembled);
781                        }
782                        nested_idx += 1;
783                    }
784                }
785
786                if !self.config.skip_unknown_segments || cursor.is_exhausted() {
787                    break;
788                }
789                let seg = &segments[cursor.position()];
790                // A segment is a true orphan from this group's perspective
791                // if its tag is not reachable from the enclosing scope
792                // (siblings above, upcoming top-level segments). Tags in
793                // this group's own local scope (e.g., RFF as an entry of
794                // a sibling nested SG6) get skipped here: we've already
795                // tried every nested group in the pass above and none
796                // consumed the segment, so a sibling qualifier-mismatch
797                // at this position will reject every subsequent pass
798                // too. Skipping moves the cursor past the mismatched
799                // content so legitimate reps that follow can still
800                // assemble. Progress is guaranteed: either a pass
801                // consumed something (cursor advanced) or we skip one
802                // segment per iteration until we hit an enclosing-scoped
803                // tag or exhaust the input.
804                if enclosing.contains(&seg.id) {
805                    break;
806                }
807                // Defensive: if the nested pass made no progress AND
808                // nothing to skip (impossible given the check above but
809                // kept so this loop always terminates), break.
810                if cursor.position() == pass_start && !self.config.skip_unknown_segments {
811                    break;
812                }
813
814                instance.skipped_positions.push(cursor.position());
815                instance.skipped_segments.push(owned_to_assembled(seg));
816                cursor.advance();
817            }
818
819            // Guard against infinite loops: if no progress was made this iteration
820            // (entry tag matched but the entry segment was rejected by e.g. a
821            // qualifier_map mismatch), stop. Pushing an empty rep per iteration
822            // would allocate unbounded memory (see collect_mig_numbers call in
823            // the instance constructor).
824            if cursor.position() == iter_start {
825                break;
826            }
827            repetitions.push(instance);
828        }
829
830        if repetitions.is_empty() {
831            Ok(None)
832        } else {
833            Ok(Some(AssembledGroup {
834                group_id: mig_group.id.clone(),
835                repetitions,
836            }))
837        }
838    }
839
840    /// Consume interleaved repetitions of variant groups.
841    ///
842    /// At each cursor position, tries all variant definitions to find which one
843    /// matches the entry segment's qualifier. Collects all reps into one
844    /// `AssembledGroup` with the shared group_id.
845    fn try_consume_variant_groups(
846        &self,
847        segments: &[OwnedSegment],
848        cursor: &mut SegmentCursor,
849        variants: &[MigSegmentGroup],
850        enclosing: &std::collections::HashSet<String>,
851    ) -> Result<Option<AssembledGroup>, AssemblyError> {
852        let group_id = variants[0].id.clone();
853        let entry_tag = variants[0]
854            .segments
855            .first()
856            .map(|s| s.id.as_str())
857            .unwrap_or("");
858        let mut all_reps = Vec::new();
859
860        while !cursor.is_exhausted() {
861            let seg = &segments[cursor.position()];
862            if !matcher::matches_segment_tag(&seg.id, entry_tag) {
863                break;
864            }
865
866            // Find which variant matches this segment's qualifier.
867            // Each variant may have its qualifier at a different element position
868            // (e.g., CCI+Z19 has qualifier at [0][0], but CCI+++Z15 at [2][0]).
869            let matched = variants.iter().find(|v| {
870                let (ei, ci) = v.variant_qualifier_position.unwrap_or((0, 0));
871                let actual_qual = seg
872                    .elements
873                    .get(ei)
874                    .and_then(|e| e.get(ci))
875                    .map(|s| s.as_str())
876                    .unwrap_or("");
877                if !v.variant_codes.is_empty() {
878                    v.variant_codes
879                        .iter()
880                        .any(|c| actual_qual.eq_ignore_ascii_case(c))
881                } else if let Some(ref expected_code) = v.variant_code {
882                    actual_qual.eq_ignore_ascii_case(expected_code)
883                } else {
884                    false
885                }
886            });
887
888            if let Some(variant) = matched {
889                if let Some(group) = self.try_consume_group(segments, cursor, variant, enclosing)? {
890                    all_reps.extend(group.repetitions);
891                } else {
892                    break;
893                }
894            } else {
895                // No variant matches — try consuming with the first variant as
896                // fallback to avoid getting stuck. This handles edge cases where
897                // the qualifier doesn't exactly match any variant code.
898                if let Some(group) =
899                    self.try_consume_group(segments, cursor, &variants[0], enclosing)?
900                {
901                    all_reps.extend(group.repetitions);
902                } else {
903                    break;
904                }
905            }
906        }
907
908        if all_reps.is_empty() {
909            Ok(None)
910        } else {
911            Ok(Some(AssembledGroup {
912                group_id,
913                repetitions: all_reps,
914            }))
915        }
916    }
917
918    /// Assemble segments with diagnostic collection.
919    ///
920    /// Returns the assembled tree plus diagnostics for segments not consumed
921    /// by the MIG-guided assembly. Existing `assemble_generic()` is unchanged.
922    pub fn assemble_with_diagnostics(
923        &self,
924        segments: &[OwnedSegment],
925    ) -> (AssembledTree, Vec<StructureDiagnostic>) {
926        let mut diagnostics = Vec::new();
927
928        let tree = match self.assemble_generic(segments) {
929            Ok(tree) => tree,
930            Err(e) => {
931                diagnostics.push(StructureDiagnostic {
932                    kind: StructureDiagnosticKind::UnexpectedSegment,
933                    segment_id: String::new(),
934                    position: 0,
935                    message: format!("Assembly failed: {e}"),
936                });
937                return (
938                    AssembledTree {
939                        segments: Vec::new(),
940                        groups: Vec::new(),
941                        post_group_start: 0,
942                        inter_group_segments: std::collections::BTreeMap::new(),
943                    },
944                    diagnostics,
945                );
946            }
947        };
948
949        // Count consumed segments in the assembled tree (skipped segments
950        // are included in this count because they were advanced past and
951        // stored — they surface as their own diagnostics below).
952        let consumed = count_tree_segments(&tree);
953
954        // Segments beyond consumed count are unconsumed tail (cascade case
955        // when skip mode is off, or structure genuinely ran out of MIG).
956        for (i, seg) in segments.iter().enumerate().skip(consumed) {
957            diagnostics.push(StructureDiagnostic {
958                kind: StructureDiagnosticKind::UnexpectedSegment,
959                segment_id: seg.id.clone(),
960                position: i,
961                message: format!(
962                    "Segment '{}' at position {} was not consumed by MIG-guided assembly",
963                    seg.id, i
964                ),
965            });
966        }
967
968        // Walk the tree and emit a diagnostic for each AHB-foreign segment
969        // the assembler advanced past (only populated when skip mode is on).
970        // Emit in input-position order so the report reads top-to-bottom.
971        let mut skipped: Vec<SkippedSegment> = Vec::new();
972        let top_level: Vec<&MigSegmentGroup> = self.mig.segment_groups.iter().collect();
973        for group in &tree.groups {
974            collect_skipped_from_group(group, &top_level, &mut skipped);
975        }
976        skipped.sort_by_key(|s| s.position);
977        for SkippedSegment {
978            position: pos,
979            tag,
980            orphaned_in,
981        } in skipped
982        {
983            diagnostics.push(match orphaned_in {
984                // Defined in the MIG, but only after an entry segment that is
985                // not there (#103) — say so instead of calling it unknown.
986                Some((group_id, entry)) => StructureDiagnostic {
987                    kind: StructureDiagnosticKind::OrphanedGroupSegment,
988                    segment_id: tag.clone(),
989                    position: pos,
990                    message: format!(
991                        "Segment '{tag}' at position {pos} belongs to group {group_id}, but that group's entry segment '{entry}' is missing; the assembler advanced past it and its content is lost",
992                    ),
993                },
994                None if mig_defines_tag(self.mig, &tag) => StructureDiagnostic {
995                    kind: StructureDiagnosticKind::SkippedUnknownSegment,
996                    segment_id: tag.clone(),
997                    position: pos,
998                    message: format!(
999                        "Segment '{tag}' at position {pos} has no slot at this point of the PID-filtered MIG; the assembler advanced past it",
1000                    ),
1001                },
1002                None => StructureDiagnostic {
1003                    kind: StructureDiagnosticKind::SkippedUnknownSegment,
1004                    segment_id: tag.clone(),
1005                    position: pos,
1006                    message: format!(
1007                        "Segment '{tag}' at position {pos} is not defined in the PID-filtered MIG; the assembler advanced past it",
1008                    ),
1009                },
1010            });
1011        }
1012
1013        (tree, diagnostics)
1014    }
1015}
1016
1017/// A segment the assembler advanced past, with the group it was orphaned from
1018/// when it is a non-entry segment of a group whose entry segment is missing.
1019struct SkippedSegment {
1020    position: usize,
1021    tag: String,
1022    /// `(group id, entry segment tag)`.
1023    orphaned_in: Option<(String, String)>,
1024}
1025
1026/// Collect the skipped segments of `group`, whose MIG definition is one of
1027/// `mig_candidates` (several when the group has variants).
1028fn collect_skipped_from_group(
1029    group: &AssembledGroup,
1030    mig_candidates: &[&MigSegmentGroup],
1031    out: &mut Vec<SkippedSegment>,
1032) {
1033    let defs: Vec<&MigSegmentGroup> = mig_candidates
1034        .iter()
1035        .copied()
1036        .filter(|g| g.id == group.group_id)
1037        .collect();
1038    let nested: Vec<&MigSegmentGroup> = defs.iter().flat_map(|g| &g.nested_groups).collect();
1039    for rep in &group.repetitions {
1040        for (i, seg) in rep.skipped_segments.iter().enumerate() {
1041            let pos = rep.skipped_positions.get(i).copied().unwrap_or(0);
1042            out.push(SkippedSegment {
1043                position: pos,
1044                tag: seg.tag.clone(),
1045                orphaned_in: orphaning_group(&nested, &seg.tag),
1046            });
1047        }
1048        for child in &rep.child_groups {
1049            collect_skipped_from_group(child, &nested, out);
1050        }
1051    }
1052}
1053
1054/// Among the direct nested groups of the group a segment was skipped in, the
1055/// one that defines `tag` only as a non-entry segment — the group `tag` could
1056/// have belonged to had its entry segment been present. `None` when `tag` also
1057/// opens one of those groups (then the skip has another cause, such as a
1058/// qualifier mismatch). Returns `(group id, entry segment tag)`.
1059fn orphaning_group(nested: &[&MigSegmentGroup], tag: &str) -> Option<(String, String)> {
1060    if nested
1061        .iter()
1062        .any(|g| g.segments.first().is_some_and(|e| e.id == tag))
1063    {
1064        return None;
1065    }
1066    nested.iter().find_map(|g| {
1067        let (entry, rest) = g.segments.split_first()?;
1068        rest.iter()
1069            .any(|s| s.id == tag)
1070            .then(|| (g.id.clone(), entry.id.clone()))
1071    })
1072}
1073
1074/// Whether any segment slot anywhere in `mig` has this tag.
1075fn mig_defines_tag(mig: &MigSchema, tag: &str) -> bool {
1076    fn in_group(g: &MigSegmentGroup, tag: &str) -> bool {
1077        g.segments.iter().any(|s| s.id == tag) || g.nested_groups.iter().any(|n| in_group(n, tag))
1078    }
1079    mig.segments.iter().any(|s| s.id == tag) || mig.segment_groups.iter().any(|g| in_group(g, tag))
1080}
1081
1082fn count_tree_segments(tree: &AssembledTree) -> usize {
1083    let mut count = tree.segments.len();
1084    for group in &tree.groups {
1085        count += count_group_segments(group);
1086    }
1087    // Count inter-group segments (e.g., UNS+D between groups)
1088    for segs in tree.inter_group_segments.values() {
1089        count += segs.len();
1090    }
1091    count
1092}
1093
1094fn count_group_segments(group: &AssembledGroup) -> usize {
1095    let mut count = 0;
1096    for rep in &group.repetitions {
1097        count += rep.segments.len();
1098        count += rep.skipped_segments.len();
1099        for child in &rep.child_groups {
1100            count += count_group_segments(child);
1101        }
1102    }
1103    count
1104}
1105
1106/// Collect tags reachable from the given group's local scope — its entry
1107/// segment, its own slot tags, and each direct nested group's entry tag.
1108/// Used by the skip-unknown retry logic to decide whether a stalled
1109/// segment belongs to the current group's responsibility (break, let
1110/// something structural consume it) or is a true orphan (skip).
1111fn group_local_scope(mig_group: &MigSegmentGroup) -> std::collections::HashSet<String> {
1112    let mut tags = std::collections::HashSet::new();
1113    for seg in &mig_group.segments {
1114        tags.insert(seg.id.clone());
1115    }
1116    for nested in &mig_group.nested_groups {
1117        if let Some(entry) = nested.segments.first() {
1118            tags.insert(entry.id.clone());
1119        }
1120    }
1121    tags
1122}
1123
1124/// Push a newly-assembled child group into an instance's `child_groups`,
1125/// merging its repetitions into any existing same-id entry. Used by the
1126/// skip-unknown retry loop in `try_consume_group` so a variant set whose
1127/// reps are split by an orphan segment still surfaces as one child group.
1128fn push_or_merge_child(child_groups: &mut Vec<AssembledGroup>, new: AssembledGroup) {
1129    if let Some(existing) = child_groups.iter_mut().find(|g| g.group_id == new.group_id) {
1130        existing.repetitions.extend(new.repetitions);
1131    } else {
1132        child_groups.push(new);
1133    }
1134}
1135
1136/// Collect all MIG `Number`s from a segment group definition, recursively.
1137///
1138/// This includes numbers from direct segments and from nested groups.
1139/// Used to populate `AssembledGroupInstance::variant_mig_numbers`.
1140fn collect_mig_numbers(group: &MigSegmentGroup) -> Vec<String> {
1141    let mut numbers = Vec::new();
1142    for seg in &group.segments {
1143        if let Some(ref num) = seg.number {
1144            numbers.push(num.clone());
1145        }
1146    }
1147    for nested in &group.nested_groups {
1148        numbers.extend(collect_mig_numbers(nested));
1149        // A nested group merged from several variants holds one variant's
1150        // segments; the others' numbers come with their entry qualifiers.
1151        for q in &nested.variant_entry_qualifiers {
1152            for n in &q.mig_numbers {
1153                if !numbers.contains(n) {
1154                    numbers.push(n.clone());
1155                }
1156            }
1157        }
1158    }
1159    numbers
1160}
1161
1162pub fn owned_to_assembled(seg: &OwnedSegment) -> AssembledSegment {
1163    AssembledSegment {
1164        tag: seg.id.clone(),
1165        elements: seg.elements.clone(),
1166        mig_number: None,
1167        segment_number: Some(seg.segment_number),
1168    }
1169}
1170
1171/// Check every code-bearing position declared on a MIG segment against the
1172/// corresponding value on the input segment.
1173///
1174/// Used by `try_consume_segment` to disambiguate slots that share the same
1175/// primary qualifier but differ on a secondary code. Returns `true` when the
1176/// input segment's values at each declared position are either empty
1177/// (optional) or in the slot's allowed set.
1178fn segment_matches_mig_codes(seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
1179    let actual_at = |el: usize, c: usize| -> &str {
1180        seg.elements
1181            .get(el)
1182            .and_then(|e| e.get(c))
1183            .map(|s| s.as_str())
1184            .unwrap_or("")
1185    };
1186    for de in &mig_seg.data_elements {
1187        if !de.codes.is_empty() {
1188            let actual = actual_at(de.position, 0);
1189            if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
1190                return false;
1191            }
1192        }
1193    }
1194    for comp in &mig_seg.composites {
1195        for de in &comp.data_elements {
1196            if !de.codes.is_empty() {
1197                let actual = actual_at(comp.position, de.position);
1198                if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
1199                    return false;
1200                }
1201            }
1202        }
1203    }
1204    true
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210    use crate::test_support::{make_mig_group, make_mig_group_with_variant, make_mig_segment};
1211
1212    fn make_owned_seg(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
1213        OwnedSegment {
1214            id: id.to_string(),
1215            elements: elements
1216                .into_iter()
1217                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
1218                .collect(),
1219            segment_number: 0,
1220        }
1221    }
1222
1223    fn make_mig_schema(segments: Vec<&str>, groups: Vec<MigSegmentGroup>) -> MigSchema {
1224        MigSchema {
1225            message_type: "UTILMD".to_string(),
1226            variant: Some("Strom".to_string()),
1227            version: "S2.1".to_string(),
1228            publication_date: "2025-03-20".to_string(),
1229            author: "BDEW".to_string(),
1230            format_version: "FV2504".to_string(),
1231            source_file: "test".to_string(),
1232            segments: segments.into_iter().map(make_mig_segment).collect(),
1233            segment_groups: groups,
1234        }
1235    }
1236
1237    #[test]
1238    fn test_assembler_top_level_segments_only() {
1239        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
1240
1241        let segments = vec![
1242            make_owned_seg("UNH", vec![vec!["001", "UTILMD:D:11A:UN:S2.1"]]),
1243            make_owned_seg("BGM", vec![vec!["E01", "DOC001"]]),
1244            make_owned_seg("DTM", vec![vec!["137", "20250101", "102"]]),
1245            make_owned_seg("UNT", vec![vec!["4", "001"]]),
1246        ];
1247
1248        let assembler = Assembler::new(&mig);
1249        let result = assembler.assemble_generic(&segments).unwrap();
1250
1251        assert_eq!(result.segments.len(), 4);
1252        assert_eq!(result.segments[0].tag, "UNH");
1253        assert_eq!(result.segments[1].tag, "BGM");
1254        assert_eq!(result.segments[2].tag, "DTM");
1255        assert_eq!(result.segments[3].tag, "UNT");
1256        assert!(result.groups.is_empty());
1257    }
1258
1259    #[test]
1260    fn test_assembler_with_segment_group() {
1261        let mig = make_mig_schema(
1262            vec!["UNH", "BGM"],
1263            vec![
1264                make_mig_group("SG2", vec!["NAD"], vec![]),
1265                make_mig_group("SG4", vec!["IDE", "STS"], vec![]),
1266            ],
1267        );
1268
1269        let segments = vec![
1270            make_owned_seg("UNH", vec![vec!["001"]]),
1271            make_owned_seg("BGM", vec![vec!["E01"]]),
1272            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1273            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
1274            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1275            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1276        ];
1277
1278        let assembler = Assembler::new(&mig);
1279        let result = assembler.assemble_generic(&segments).unwrap();
1280
1281        // Top-level: UNH, BGM
1282        assert_eq!(result.segments.len(), 2);
1283        // SG2: 2 repetitions (two NAD segments)
1284        assert_eq!(result.groups.len(), 2);
1285        assert_eq!(result.groups[0].group_id, "SG2");
1286        assert_eq!(result.groups[0].repetitions.len(), 2);
1287        assert_eq!(result.groups[0].repetitions[0].segments[0].tag, "NAD");
1288        assert_eq!(result.groups[0].repetitions[1].segments[0].tag, "NAD");
1289        // SG4: 1 repetition (IDE + STS)
1290        assert_eq!(result.groups[1].group_id, "SG4");
1291        assert_eq!(result.groups[1].repetitions.len(), 1);
1292        assert_eq!(result.groups[1].repetitions[0].segments.len(), 2);
1293    }
1294
1295    #[test]
1296    fn test_assembler_nested_groups() {
1297        let sg3 = make_mig_group("SG3", vec!["CTA", "COM"], vec![]);
1298        let mig = make_mig_schema(
1299            vec!["UNH", "BGM"],
1300            vec![make_mig_group("SG2", vec!["NAD"], vec![sg3])],
1301        );
1302
1303        let segments = vec![
1304            make_owned_seg("UNH", vec![vec!["001"]]),
1305            make_owned_seg("BGM", vec![vec!["E01"]]),
1306            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1307            make_owned_seg("CTA", vec![vec!["IC", "Kontakt"]]),
1308            make_owned_seg("COM", vec![vec!["040@example.com", "EM"]]),
1309        ];
1310
1311        let assembler = Assembler::new(&mig);
1312        let result = assembler.assemble_generic(&segments).unwrap();
1313
1314        // SG2 has 1 repetition
1315        let sg2 = &result.groups[0];
1316        assert_eq!(sg2.group_id, "SG2");
1317        assert_eq!(sg2.repetitions.len(), 1);
1318
1319        let sg2_inst = &sg2.repetitions[0];
1320        assert_eq!(sg2_inst.segments[0].tag, "NAD");
1321
1322        // SG3 nested inside SG2
1323        assert_eq!(sg2_inst.child_groups.len(), 1);
1324        let sg3 = &sg2_inst.child_groups[0];
1325        assert_eq!(sg3.group_id, "SG3");
1326        assert_eq!(sg3.repetitions[0].segments.len(), 2);
1327        assert_eq!(sg3.repetitions[0].segments[0].tag, "CTA");
1328        assert_eq!(sg3.repetitions[0].segments[1].tag, "COM");
1329    }
1330
1331    #[test]
1332    fn test_assembler_optional_segments_skipped() {
1333        // MIG expects UNH, BGM, DTM, UNT but input has no DTM
1334        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
1335
1336        let segments = vec![
1337            make_owned_seg("UNH", vec![vec!["001"]]),
1338            make_owned_seg("BGM", vec![vec!["E01"]]),
1339            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1340        ];
1341
1342        let assembler = Assembler::new(&mig);
1343        let result = assembler.assemble_generic(&segments).unwrap();
1344
1345        // DTM is skipped (optional), UNT consumed
1346        assert_eq!(result.segments.len(), 3);
1347        assert_eq!(result.segments[0].tag, "UNH");
1348        assert_eq!(result.segments[1].tag, "BGM");
1349        assert_eq!(result.segments[2].tag, "UNT");
1350    }
1351
1352    #[test]
1353    fn test_assembler_empty_segments() {
1354        let mig = make_mig_schema(vec!["UNH"], vec![]);
1355        let assembler = Assembler::new(&mig);
1356        let result = assembler.assemble_generic(&[]).unwrap();
1357        assert!(result.segments.is_empty());
1358        assert!(result.groups.is_empty());
1359    }
1360
1361    #[test]
1362    fn test_assembler_preserves_element_data() {
1363        let mig = make_mig_schema(vec!["DTM"], vec![]);
1364
1365        let segments = vec![make_owned_seg(
1366            "DTM",
1367            vec![vec!["137", "202501010000+01", "303"]],
1368        )];
1369
1370        let assembler = Assembler::new(&mig);
1371        let result = assembler.assemble_generic(&segments).unwrap();
1372
1373        let dtm = &result.segments[0];
1374        assert_eq!(dtm.elements[0][0], "137");
1375        assert_eq!(dtm.elements[0][1], "202501010000+01");
1376        assert_eq!(dtm.elements[0][2], "303");
1377    }
1378
1379    #[test]
1380    fn test_group_instance_as_assembled_tree() {
1381        // Build an SG4 instance with root segments (IDE, STS) and child groups (SG5)
1382        let sg5 = AssembledGroup {
1383            group_id: "SG5".to_string(),
1384            repetitions: vec![AssembledGroupInstance {
1385                segments: vec![AssembledSegment {
1386                    tag: "LOC".to_string(),
1387                    elements: vec![vec!["Z16".to_string(), "DE000111222333".to_string()]],
1388                    mig_number: None,
1389                    segment_number: None,
1390                }],
1391                child_groups: vec![],
1392                entry_mig_number: None,
1393                variant_mig_numbers: vec![],
1394                skipped_segments: vec![],
1395                skipped_positions: Vec::new(),
1396            }],
1397        };
1398
1399        let sg4_instance = AssembledGroupInstance {
1400            segments: vec![
1401                AssembledSegment {
1402                    tag: "IDE".to_string(),
1403                    elements: vec![vec!["24".to_string(), "TX001".to_string()]],
1404                    mig_number: None,
1405                    segment_number: None,
1406                },
1407                AssembledSegment {
1408                    tag: "STS".to_string(),
1409                    elements: vec![vec!["7".to_string()]],
1410                    mig_number: None,
1411                    segment_number: None,
1412                },
1413            ],
1414            child_groups: vec![sg5],
1415            entry_mig_number: None,
1416            variant_mig_numbers: vec![],
1417            skipped_segments: vec![],
1418            skipped_positions: Vec::new(),
1419        };
1420
1421        let sub_tree = sg4_instance.as_assembled_tree();
1422
1423        // Root segments of sub-tree are the SG4 instance's segments
1424        assert_eq!(sub_tree.segments.len(), 2);
1425        assert_eq!(sub_tree.segments[0].tag, "IDE");
1426        assert_eq!(sub_tree.segments[1].tag, "STS");
1427
1428        // Groups of sub-tree are the SG4 instance's child groups
1429        assert_eq!(sub_tree.groups.len(), 1);
1430        assert_eq!(sub_tree.groups[0].group_id, "SG5");
1431
1432        // post_group_start marks where root segments end
1433        assert_eq!(sub_tree.post_group_start, 2);
1434    }
1435
1436    #[test]
1437    fn test_assembler_from_parsed_edifact() {
1438        // End-to-end: parse raw EDIFACT, then assemble
1439        let input = b"UNA:+.? 'UNB+UNOC:3+SENDER+RECEIVER+210101:1200+REF001'UNH+MSG001+UTILMD:D:11A:UN:S2.1'BGM+E01+DOC001+9'DTM+137:20250101:102'UNT+3+MSG001'UNZ+1+REF001'";
1440        let segments = crate::tokenize::parse_to_segments(input).unwrap();
1441
1442        let mig = make_mig_schema(vec!["UNB", "UNH", "BGM", "DTM", "UNT", "UNZ"], vec![]);
1443
1444        let assembler = Assembler::new(&mig);
1445        let result = assembler.assemble_generic(&segments).unwrap();
1446
1447        assert!(result.segments.iter().any(|s| s.tag == "UNH"));
1448        assert!(result.segments.iter().any(|s| s.tag == "BGM"));
1449        assert!(result.segments.iter().any(|s| s.tag == "DTM"));
1450    }
1451
1452    #[test]
1453    fn test_assemble_with_diagnostics_clean_input() {
1454        let mig = make_mig_schema(vec!["UNH", "BGM", "UNT"], vec![]);
1455        let segments = vec![
1456            make_owned_seg("UNH", vec![vec!["001"]]),
1457            make_owned_seg("BGM", vec![vec!["E01"]]),
1458            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1459        ];
1460        let assembler = Assembler::new(&mig);
1461        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1462        assert_eq!(tree.segments.len(), 3);
1463        assert!(
1464            diagnostics.is_empty(),
1465            "Clean input should have no diagnostics"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_assemble_with_diagnostics_unconsumed_segments() {
1471        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![]);
1472        let segments = vec![
1473            make_owned_seg("UNH", vec![vec!["001"]]),
1474            make_owned_seg("BGM", vec![vec!["E01"]]),
1475            make_owned_seg("FTX", vec![vec!["AAA", "extra text"]]),
1476        ];
1477        let assembler = Assembler::new(&mig);
1478        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1479        assert_eq!(tree.segments.len(), 2);
1480        assert_eq!(diagnostics.len(), 1);
1481        assert_eq!(
1482            diagnostics[0].kind,
1483            StructureDiagnosticKind::UnexpectedSegment
1484        );
1485        assert_eq!(diagnostics[0].segment_id, "FTX");
1486        assert_eq!(diagnostics[0].position, 2);
1487    }
1488
1489    #[test]
1490    fn test_assemble_with_diagnostics_multiple_unconsumed() {
1491        let mig = make_mig_schema(vec!["UNH"], vec![]);
1492        let segments = vec![
1493            make_owned_seg("UNH", vec![vec!["001"]]),
1494            make_owned_seg("FOO", vec![]),
1495            make_owned_seg("BAR", vec![]),
1496            make_owned_seg("BAZ", vec![]),
1497        ];
1498        let assembler = Assembler::new(&mig);
1499        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1500        assert_eq!(tree.segments.len(), 1);
1501        assert_eq!(diagnostics.len(), 3);
1502        assert_eq!(diagnostics[0].segment_id, "FOO");
1503        assert_eq!(diagnostics[1].segment_id, "BAR");
1504        assert_eq!(diagnostics[2].segment_id, "BAZ");
1505    }
1506
1507    // ── Non-entry segment mig_number assignment tests ──
1508
1509    #[test]
1510    fn test_non_entry_segments_get_mig_number_from_bounded_slots() {
1511        // MIG group SG4 has entry IDE + two numbered DTMs + STS.
1512        // The assembler should assign mig_number from the MIG slots to
1513        // each non-entry segment via the bounded consumption path.
1514        use crate::test_support::make_mig_segment_numbered;
1515
1516        let sg4 = MigSegmentGroup {
1517            segments: vec![
1518                make_mig_segment_numbered("IDE", "00020"),
1519                make_mig_segment_numbered("DTM", "00023"),
1520                make_mig_segment_numbered("DTM", "00024"),
1521                make_mig_segment_numbered("STS", "00035"),
1522            ],
1523            ..make_mig_group("SG4", vec![], vec![])
1524        };
1525        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1526
1527        let segments = vec![
1528            make_owned_seg("UNH", vec![vec!["001"]]),
1529            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1530            make_owned_seg("DTM", vec![vec!["92", "202505312200+00", "303"]]),
1531            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1532            make_owned_seg("STS", vec![vec!["7"], vec![], vec!["E01"]]),
1533        ];
1534
1535        let assembler = Assembler::new(&mig);
1536        let tree = assembler.assemble_generic(&segments).unwrap();
1537
1538        let sg4_instance = &tree.groups[0].repetitions[0];
1539
1540        // IDE (entry) gets mig_number from try_consume_segment
1541        assert_eq!(sg4_instance.segments[0].tag, "IDE");
1542        assert_eq!(
1543            sg4_instance.segments[0].mig_number.as_deref(),
1544            Some("00020")
1545        );
1546
1547        // DTM+92 gets mig_number "00023" from first DTM slot
1548        assert_eq!(sg4_instance.segments[1].tag, "DTM");
1549        assert_eq!(
1550            sg4_instance.segments[1].mig_number.as_deref(),
1551            Some("00023")
1552        );
1553
1554        // DTM+93 gets mig_number "00024" from second DTM slot
1555        assert_eq!(sg4_instance.segments[2].tag, "DTM");
1556        assert_eq!(
1557            sg4_instance.segments[2].mig_number.as_deref(),
1558            Some("00024")
1559        );
1560
1561        // STS gets mig_number "00035"
1562        assert_eq!(sg4_instance.segments[3].tag, "STS");
1563        assert_eq!(
1564            sg4_instance.segments[3].mig_number.as_deref(),
1565            Some("00035")
1566        );
1567
1568        // variant_mig_numbers should contain all four
1569        assert!(sg4_instance
1570            .variant_mig_numbers
1571            .contains(&"00020".to_string()));
1572        assert!(sg4_instance
1573            .variant_mig_numbers
1574            .contains(&"00023".to_string()));
1575        assert!(sg4_instance
1576            .variant_mig_numbers
1577            .contains(&"00024".to_string()));
1578        assert!(sg4_instance
1579            .variant_mig_numbers
1580            .contains(&"00035".to_string()));
1581    }
1582
1583    #[test]
1584    fn test_greedy_extra_segments_get_no_mig_number() {
1585        // MIG defines 1 DTM slot, but input has 2 DTMs.
1586        // First DTM gets mig_number from bounded path, second gets None (greedy extra).
1587        use crate::test_support::make_mig_segment_numbered;
1588
1589        let sg4 = MigSegmentGroup {
1590            segments: vec![
1591                make_mig_segment_numbered("IDE", "00020"),
1592                make_mig_segment_numbered("DTM", "00023"),
1593            ],
1594            ..make_mig_group("SG4", vec![], vec![])
1595        };
1596        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1597
1598        let segments = vec![
1599            make_owned_seg("UNH", vec![vec!["001"]]),
1600            make_owned_seg("IDE", vec![vec!["24"]]),
1601            make_owned_seg("DTM", vec![vec!["92", "20250531"]]),
1602            make_owned_seg("DTM", vec![vec!["93", "20251231"]]), // extra beyond MIG
1603        ];
1604
1605        let assembler = Assembler::new(&mig);
1606        let tree = assembler.assemble_generic(&segments).unwrap();
1607
1608        let sg4_instance = &tree.groups[0].repetitions[0];
1609        assert_eq!(sg4_instance.segments.len(), 3); // IDE + 2 DTMs
1610
1611        // First DTM: bounded slot → mig_number set
1612        assert_eq!(
1613            sg4_instance.segments[1].mig_number.as_deref(),
1614            Some("00023")
1615        );
1616
1617        // Second DTM: greedy extra → mig_number None
1618        assert_eq!(sg4_instance.segments[2].mig_number, None);
1619    }
1620
1621    // ── Qualifier-aware assembly tests ──
1622
1623    #[test]
1624    fn test_qualifier_map_prevents_wrong_slot_consumption() {
1625        // MIG defines DTM(00023) + DTM(00024). Input has only DTM+93.
1626        // Without qualifier map: DTM+93 consumed by slot 00023 (wrong).
1627        // With qualifier map: slot 00023 expects "92", skips DTM+93.
1628        //   Slot 00024 expects "93", consumes DTM+93 correctly.
1629        use crate::test_support::make_mig_segment_numbered;
1630        use std::collections::HashMap;
1631
1632        let sg4 = MigSegmentGroup {
1633            segments: vec![
1634                make_mig_segment_numbered("IDE", "00020"),
1635                make_mig_segment_numbered("DTM", "00023"),
1636                make_mig_segment_numbered("DTM", "00024"),
1637            ],
1638            ..make_mig_group("SG4", vec![], vec![])
1639        };
1640        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1641
1642        let segments = vec![
1643            make_owned_seg("UNH", vec![vec!["001"]]),
1644            make_owned_seg("IDE", vec![vec!["24"]]),
1645            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1646        ];
1647
1648        let mut qualifier_map = HashMap::new();
1649        qualifier_map.insert("00023".to_string(), (0, 0, "92".to_string()));
1650        qualifier_map.insert("00024".to_string(), (0, 0, "93".to_string()));
1651
1652        let config = AssemblerConfig {
1653            skip_unknown_segments: false,
1654            qualifier_map,
1655            ..Default::default()
1656        };
1657        let assembler = Assembler::with_config(&mig, config);
1658        let tree = assembler.assemble_generic(&segments).unwrap();
1659
1660        let sg4_instance = &tree.groups[0].repetitions[0];
1661
1662        // DTM+93 should be consumed by slot 00024, NOT slot 00023
1663        assert_eq!(sg4_instance.segments.len(), 2); // IDE + DTM+93
1664        let dtm = &sg4_instance.segments[1];
1665        assert_eq!(dtm.tag, "DTM");
1666        assert_eq!(
1667            dtm.mig_number.as_deref(),
1668            Some("00024"),
1669            "DTM+93 should get mig_number 00024 (not 00023)"
1670        );
1671    }
1672
1673    #[test]
1674    fn test_group_entry_qualifier_mismatch_does_not_infinite_loop() {
1675        // Regression: when a group's entry segment has a qualifier_map entry
1676        // but the input segment's qualifier does not match, the outer
1677        // `while !cursor.is_exhausted()` loop in try_consume_group used to
1678        // spin forever — entry tag matched, so the loop kept going, but
1679        // try_consume_segment rejected the segment on qualifier mismatch, so
1680        // the cursor never advanced. Each iteration allocated a fresh
1681        // variant_mig_numbers Vec via collect_mig_numbers, driving unbounded
1682        // memory growth (observed: 3.5 GB → OOM in ~3 s on staging for
1683        // FV2604/UTILMD_Gas/PID 44004 with LOC+172).
1684        use crate::test_support::make_mig_segment_numbered;
1685        use std::collections::HashMap;
1686
1687        let sg5 = MigSegmentGroup {
1688            segments: vec![make_mig_segment_numbered("LOC", "00050")],
1689            ..make_mig_group("SG5", vec![], vec![])
1690        };
1691        let mig = make_mig_schema(vec!["UNH"], vec![sg5]);
1692
1693        let segments = vec![
1694            make_owned_seg("UNH", vec![vec!["001"]]),
1695            // LOC entry tag matches, but qualifier "172" ≠ expected "Z16"
1696            make_owned_seg("LOC", vec![vec!["172"], vec!["92003964705"]]),
1697        ];
1698
1699        let mut qualifier_map = HashMap::new();
1700        qualifier_map.insert("00050".to_string(), (0, 0, "Z16".to_string()));
1701
1702        let config = AssemblerConfig {
1703            skip_unknown_segments: false,
1704            qualifier_map,
1705            ..Default::default()
1706        };
1707        let assembler = Assembler::with_config(&mig, config);
1708
1709        // Before the fix this would loop forever. Bound the assertion with a
1710        // generous wall-clock guard so a regression is a clear test failure
1711        // rather than a hanging CI job.
1712        let start = std::time::Instant::now();
1713        let tree = assembler.assemble_generic(&segments).unwrap();
1714        assert!(
1715            start.elapsed() < std::time::Duration::from_secs(5),
1716            "assembly took {:?} — suspected infinite-loop regression",
1717            start.elapsed()
1718        );
1719
1720        // LOC+172 didn't match SG5's qualifier, so SG5 should be empty.
1721        // The LOC segment remains unconsumed (caller will surface it as a
1722        // structure diagnostic).
1723        assert!(tree.groups.is_empty());
1724    }
1725
1726    // ── Skip-unknown-segments tests ──
1727
1728    #[test]
1729    fn test_skip_unknown_segment_between_slots() {
1730        // MIG group expects [SEQ, CCI], input has [SEQ, RFF, CCI].
1731        // With skip ON, RFF is skipped and CCI is consumed.
1732        // With skip OFF (default), CCI is lost because RFF stalls the cursor.
1733        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1734        let mig = make_mig_schema(vec!["UNH"], vec![sg8.clone()]);
1735
1736        let segments = vec![
1737            make_owned_seg("UNH", vec![vec!["001"]]),
1738            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1739            make_owned_seg("RFF", vec![vec!["Z38", "CROSSREF"]]),
1740            make_owned_seg("CCI", vec![vec!["Z30"]]),
1741        ];
1742
1743        // Skip OFF: CCI not consumed (RFF stalls cursor after SEQ)
1744        let off = Assembler::new(&mig);
1745        let tree_off = off.assemble_generic(&segments).unwrap();
1746        let sg8_off = &tree_off.groups[0];
1747        assert_eq!(sg8_off.repetitions[0].segments.len(), 1); // Only SEQ
1748        assert_eq!(sg8_off.repetitions[0].segments[0].tag, "SEQ");
1749
1750        // Skip ON: RFF skipped, CCI consumed
1751        let on = Assembler::with_config(
1752            &mig,
1753            AssemblerConfig {
1754                skip_unknown_segments: true,
1755                ..Default::default()
1756            },
1757        );
1758        let tree_on = on.assemble_generic(&segments).unwrap();
1759        let sg8_on = &tree_on.groups[0];
1760        assert_eq!(sg8_on.repetitions[0].segments.len(), 2); // SEQ + CCI
1761        assert_eq!(sg8_on.repetitions[0].segments[0].tag, "SEQ");
1762        assert_eq!(sg8_on.repetitions[0].segments[1].tag, "CCI");
1763    }
1764
1765    #[test]
1766    fn test_skip_preserves_on_instance() {
1767        // Skipped segments are stored in instance.skipped_segments
1768        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1769        let mig = make_mig_schema(vec!["UNH"], vec![sg8]);
1770
1771        let segments = vec![
1772            make_owned_seg("UNH", vec![vec!["001"]]),
1773            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1774            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1775            make_owned_seg("DTM", vec![vec!["92", "20250101"]]),
1776            make_owned_seg("CCI", vec![vec!["Z30"]]),
1777        ];
1778
1779        let assembler = Assembler::with_config(
1780            &mig,
1781            AssemblerConfig {
1782                skip_unknown_segments: true,
1783                ..Default::default()
1784            },
1785        );
1786        let tree = assembler.assemble_generic(&segments).unwrap();
1787        let instance = &tree.groups[0].repetitions[0];
1788
1789        assert_eq!(instance.segments.len(), 2); // SEQ + CCI
1790        assert_eq!(instance.skipped_segments.len(), 2); // RFF + DTM
1791        assert_eq!(instance.skipped_segments[0].tag, "RFF");
1792        assert_eq!(instance.skipped_segments[1].tag, "DTM");
1793    }
1794
1795    #[test]
1796    fn test_skip_mode_off_default() {
1797        // Assembler::new() doesn't skip (backwards compat)
1798        let mig = make_mig_schema(vec![], vec![]);
1799        let assembler = Assembler::new(&mig);
1800        assert!(!assembler.config.skip_unknown_segments);
1801    }
1802
1803    #[test]
1804    fn test_skip_does_not_consume_nested_group_entry() {
1805        // Skip must NOT consume segments that are nested group entries.
1806        // SG4 expects [IDE, STS], nested SG5 expects [LOC].
1807        // Input: IDE, FOO, STS, LOC. FOO should be skipped, LOC goes to SG5.
1808        let sg5 = make_mig_group("SG5", vec!["LOC"], vec![]);
1809        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg5]);
1810        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1811
1812        let segments = vec![
1813            make_owned_seg("UNH", vec![vec!["001"]]),
1814            make_owned_seg("IDE", vec![vec!["24"]]),
1815            make_owned_seg("FOO", vec![vec!["unknown"]]),
1816            make_owned_seg("STS", vec![vec!["7"]]),
1817            make_owned_seg("LOC", vec![vec!["Z16"]]),
1818        ];
1819
1820        let assembler = Assembler::with_config(
1821            &mig,
1822            AssemblerConfig {
1823                skip_unknown_segments: true,
1824                ..Default::default()
1825            },
1826        );
1827        let tree = assembler.assemble_generic(&segments).unwrap();
1828        let sg4 = &tree.groups[0];
1829        let inst = &sg4.repetitions[0];
1830
1831        // IDE + STS consumed, FOO skipped
1832        assert_eq!(inst.segments.len(), 2);
1833        assert_eq!(inst.segments[0].tag, "IDE");
1834        assert_eq!(inst.segments[1].tag, "STS");
1835        assert_eq!(inst.skipped_segments.len(), 1);
1836        assert_eq!(inst.skipped_segments[0].tag, "FOO");
1837
1838        // LOC went to nested SG5
1839        assert_eq!(inst.child_groups.len(), 1);
1840        assert_eq!(inst.child_groups[0].group_id, "SG5");
1841        assert_eq!(inst.child_groups[0].repetitions[0].segments[0].tag, "LOC");
1842    }
1843
1844    /// #103: SG10 rendered as `CAV` without its entry segment `CCI`. CAV is
1845    /// defined in the MIG, so the diagnostic must not claim it is unknown — it
1846    /// must say the group's entry segment is missing, since that is what the
1847    /// sender has to fix and what made the group's content disappear.
1848    #[test]
1849    fn test_diagnostics_orphaned_group_segment_without_entry() {
1850        let sg10 = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
1851        let sg8 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "Z03");
1852        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1853        let sg4 = make_mig_group("SG4", vec!["IDE"], vec![sg8, sg12]);
1854        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1855
1856        let segments = vec![
1857            make_owned_seg("UNH", vec![vec!["001"]]),
1858            make_owned_seg("IDE", vec![vec!["24"]]),
1859            make_owned_seg("SEQ", vec![vec!["Z03"]]),
1860            make_owned_seg("CAV", vec![vec!["Z30", "", "", "X"]]),
1861            make_owned_seg("FOO", vec![vec!["unknown"]]),
1862            make_owned_seg("NAD", vec![vec!["Z07"]]),
1863        ];
1864
1865        let assembler = Assembler::with_config(
1866            &mig,
1867            AssemblerConfig {
1868                skip_unknown_segments: true,
1869                ..Default::default()
1870            },
1871        );
1872        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1873
1874        // The NAD after the orphan still assembles.
1875        let sg4 = &tree.groups[0].repetitions[0];
1876        assert!(sg4.child_groups.iter().any(|g| g.group_id == "SG12"));
1877
1878        let orphaned: Vec<_> = diagnostics
1879            .iter()
1880            .filter(|d| d.kind == StructureDiagnosticKind::OrphanedGroupSegment)
1881            .collect();
1882        assert_eq!(orphaned.len(), 1, "{diagnostics:?}");
1883        assert_eq!(orphaned[0].segment_id, "CAV");
1884        assert_eq!(orphaned[0].position, 3);
1885        assert!(
1886            orphaned[0].message.contains("SG10") && orphaned[0].message.contains("'CCI'"),
1887            "message must name the group and its missing entry segment: {}",
1888            orphaned[0].message
1889        );
1890        assert!(
1891            !orphaned[0].message.contains("not defined"),
1892            "CAV is defined in the MIG: {}",
1893            orphaned[0].message
1894        );
1895
1896        // A segment the MIG really does not define keeps its own kind.
1897        let skipped: Vec<_> = diagnostics
1898            .iter()
1899            .filter(|d| d.kind == StructureDiagnosticKind::SkippedUnknownSegment)
1900            .collect();
1901        assert_eq!(skipped.len(), 1, "{diagnostics:?}");
1902        assert_eq!(skipped[0].segment_id, "FOO");
1903    }
1904
1905    #[test]
1906    fn test_skip_unknown_between_nested_group_reps() {
1907        // PID 55035 regression: an AHB-foreign segment sitting between two
1908        // reps of a nested variant-aware group currently stalls the cursor
1909        // and cascades: every subsequent valid rep is lost. With skip mode on,
1910        // the orphan should be recorded and the following reps consumed.
1911        //
1912        // Shape:
1913        //   SG4 [IDE, STS]
1914        //     SG8 (variant ZD7) [SEQ]
1915        //       SG10 [CCI]
1916        //     SG8 (variant Z98) [SEQ]
1917        //     SG12 [NAD]
1918        //
1919        // Input:
1920        //   IDE, STS, SEQ+ZD7, CCI+Z30, FOO+<orphan>, SEQ+Z98, NAD+MS
1921        //
1922        // Expected with skip ON: SG8 has 2 reps (ZD7 + Z98), SG12 has 1 rep,
1923        // and the orphan FOO sits in SG4's skipped_segments.
1924        let sg10 = make_mig_group("SG10", vec!["CCI"], vec![]);
1925        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
1926        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![], "Z98");
1927        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1928        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg8_zd7, sg8_z98, sg12]);
1929        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1930
1931        let segments = vec![
1932            make_owned_seg("UNH", vec![vec!["001"]]),
1933            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1934            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1935            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1936            make_owned_seg("CCI", vec![vec!["Z30"]]),
1937            make_owned_seg("FOO", vec![vec!["orphan"]]),
1938            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1939            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1940        ];
1941
1942        let assembler = Assembler::with_config(
1943            &mig,
1944            AssemblerConfig {
1945                skip_unknown_segments: true,
1946                ..Default::default()
1947            },
1948        );
1949        let tree = assembler.assemble_generic(&segments).unwrap();
1950
1951        assert_eq!(tree.groups.len(), 1);
1952        let sg4_inst = &tree.groups[0].repetitions[0];
1953        // SG4 nested child groups: one SG8 (combined ZD7+Z98) and one SG12
1954        assert_eq!(sg4_inst.child_groups.len(), 2, "expected SG8 + SG12");
1955        let sg8_tree = &sg4_inst.child_groups[0];
1956        assert_eq!(sg8_tree.group_id, "SG8");
1957        assert_eq!(
1958            sg8_tree.repetitions.len(),
1959            2,
1960            "SG8 should have both ZD7 and Z98 reps after orphan skip"
1961        );
1962        assert_eq!(sg8_tree.repetitions[0].segments[0].elements[0][0], "ZD7");
1963        assert_eq!(sg8_tree.repetitions[1].segments[0].elements[0][0], "Z98");
1964
1965        let sg12_tree = &sg4_inst.child_groups[1];
1966        assert_eq!(sg12_tree.group_id, "SG12");
1967        assert_eq!(sg12_tree.repetitions.len(), 1);
1968
1969        // Orphan FOO is recorded on whichever instance was active when it
1970        // was encountered. It may live on SG4, on the first SG8 rep, or on
1971        // that rep's child SG10 — the important thing is that it's captured
1972        // exactly once and the subsequent valid reps were still consumed.
1973        fn count_foo(inst: &AssembledGroupInstance) -> usize {
1974            let mut n = inst
1975                .skipped_segments
1976                .iter()
1977                .filter(|s| s.tag == "FOO")
1978                .count();
1979            for child in &inst.child_groups {
1980                for rep in &child.repetitions {
1981                    n += count_foo(rep);
1982                }
1983            }
1984            n
1985        }
1986        assert_eq!(
1987            count_foo(sg4_inst),
1988            1,
1989            "FOO should be recorded exactly once"
1990        );
1991    }
1992
1993    #[test]
1994    fn test_skip_off_preserves_cascade_behavior() {
1995        // Same structure as above, but with skip OFF the orphan must still
1996        // stall the cursor (callers relying on strict assembly shouldn't
1997        // suddenly see orphans silently swallowed).
1998        let sg10 = make_mig_group("SG10", vec!["CCI"], vec![]);
1999        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "ZD7");
2000        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![], "Z98");
2001        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
2002        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg8_zd7, sg8_z98, sg12]);
2003        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
2004
2005        let segments = vec![
2006            make_owned_seg("UNH", vec![vec!["001"]]),
2007            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
2008            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
2009            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2010            make_owned_seg("CCI", vec![vec!["Z30"]]),
2011            make_owned_seg("FOO", vec![vec!["orphan"]]),
2012            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2013            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
2014        ];
2015
2016        let assembler = Assembler::new(&mig);
2017        let tree = assembler.assemble_generic(&segments).unwrap();
2018
2019        let sg4_inst = &tree.groups[0].repetitions[0];
2020        let sg8_tree = sg4_inst
2021            .child_groups
2022            .iter()
2023            .find(|g| g.group_id == "SG8")
2024            .expect("SG8 should still be present");
2025        // Only the first SG8 rep gets consumed; Z98 and NAD stall behind FOO.
2026        assert_eq!(sg8_tree.repetitions.len(), 1);
2027        assert!(
2028            sg4_inst.skipped_segments.iter().all(|s| s.tag != "FOO"),
2029            "FOO must not be skipped when skip mode is off"
2030        );
2031    }
2032
2033    #[test]
2034    fn test_roundtrip_with_skip() {
2035        // Full roundtrip: assemble with skip → disassemble → byte-identical
2036        // including skipped segments in the output.
2037        use crate::disassembler::Disassembler;
2038        use crate::renderer::render_edifact;
2039
2040        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
2041        let mig = make_mig_schema(vec!["UNH", "UNT"], vec![sg8]);
2042
2043        let segments = vec![
2044            make_owned_seg("UNH", vec![vec!["001"]]),
2045            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2046            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2047            make_owned_seg("CCI", vec![vec!["Z30"]]),
2048            make_owned_seg("UNT", vec![vec!["4", "001"]]),
2049        ];
2050
2051        let assembler = Assembler::with_config(
2052            &mig,
2053            AssemblerConfig {
2054                skip_unknown_segments: true,
2055                ..Default::default()
2056            },
2057        );
2058        let tree = assembler.assemble_generic(&segments).unwrap();
2059
2060        let disassembler = Disassembler::new(&mig);
2061        let dis = disassembler.disassemble(&tree);
2062        let delimiters = edifact_primitives::EdifactDelimiters::default();
2063        let rendered = render_edifact(&dis, &delimiters);
2064
2065        // All 5 segments should appear in output (including skipped RFF).
2066        // Disassembler emits MIG-guided segments first (SEQ, CCI),
2067        // then skipped segments (RFF) — so order within the group differs
2068        // from the original input, but all content is preserved.
2069        assert_eq!(dis.len(), 5);
2070        assert_eq!(dis[0].tag, "UNH");
2071        assert_eq!(dis[1].tag, "SEQ");
2072        assert_eq!(dis[2].tag, "CCI");
2073        assert_eq!(dis[3].tag, "RFF"); // skipped → emitted after MIG segments
2074        assert_eq!(dis[4].tag, "UNT");
2075
2076        // Rendered output contains all segments
2077        assert!(rendered.contains("UNH+001"));
2078        assert!(rendered.contains("SEQ+Z98"));
2079        assert!(rendered.contains("RFF+Z38:REF1"));
2080        assert!(rendered.contains("CCI+Z30"));
2081        assert!(rendered.contains("UNT+4:001"));
2082    }
2083
2084    // ── Variant-aware assembly tests ──
2085
2086    #[test]
2087    fn test_variant_groups_interleaved_reps() {
2088        // Two SG8 variant definitions: one for SEQ+ZD7, one for SEQ+Z98.
2089        // Input has interleaved reps: ZD7, Z98, ZD7, Z98.
2090        // All should be collected into one SG8 group with 4 reps.
2091        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2092        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2093
2094        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2095
2096        let segments = vec![
2097            make_owned_seg("UNH", vec![vec!["001"]]),
2098            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2099            make_owned_seg("CCI", vec![vec!["Z30"]]),
2100            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2101            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2102            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2103            make_owned_seg("CCI", vec![vec!["Z31"]]),
2104            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2105            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
2106        ];
2107
2108        let assembler = Assembler::new(&mig);
2109        let result = assembler.assemble_generic(&segments).unwrap();
2110
2111        assert_eq!(result.segments.len(), 1); // UNH
2112        assert_eq!(result.groups.len(), 1); // One combined SG8
2113        let sg8 = &result.groups[0];
2114        assert_eq!(sg8.group_id, "SG8");
2115        assert_eq!(sg8.repetitions.len(), 4);
2116
2117        // ZD7 reps have SEQ+CCI, Z98 reps have SEQ+RFF
2118        assert_eq!(sg8.repetitions[0].segments[0].elements[0][0], "ZD7");
2119        assert_eq!(sg8.repetitions[0].segments[1].tag, "CCI");
2120        assert_eq!(sg8.repetitions[1].segments[0].elements[0][0], "Z98");
2121        assert_eq!(sg8.repetitions[1].segments[1].tag, "RFF");
2122        assert_eq!(sg8.repetitions[2].segments[0].elements[0][0], "ZD7");
2123        assert_eq!(sg8.repetitions[3].segments[0].elements[0][0], "Z98");
2124    }
2125
2126    #[test]
2127    fn test_variant_groups_single_variant_type() {
2128        // Only Z98 reps, no ZD7 — still works with variant matching
2129        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2130        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2131
2132        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2133
2134        let segments = vec![
2135            make_owned_seg("UNH", vec![vec!["001"]]),
2136            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2137            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2138            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2139            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
2140        ];
2141
2142        let assembler = Assembler::new(&mig);
2143        let result = assembler.assemble_generic(&segments).unwrap();
2144
2145        assert_eq!(result.groups.len(), 1);
2146        assert_eq!(result.groups[0].repetitions.len(), 2);
2147        assert_eq!(
2148            result.groups[0].repetitions[0].segments[0].elements[0][0],
2149            "Z98"
2150        );
2151        assert_eq!(
2152            result.groups[0].repetitions[1].segments[0].elements[0][0],
2153            "Z98"
2154        );
2155    }
2156
2157    #[test]
2158    fn test_non_variant_groups_unchanged() {
2159        // Groups without variant_code behave exactly as before
2160        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
2161        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![]);
2162
2163        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![sg2, sg4]);
2164
2165        let segments = vec![
2166            make_owned_seg("UNH", vec![vec!["001"]]),
2167            make_owned_seg("BGM", vec![vec!["E01"]]),
2168            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
2169            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
2170            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
2171            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
2172        ];
2173
2174        let assembler = Assembler::new(&mig);
2175        let result = assembler.assemble_generic(&segments).unwrap();
2176
2177        assert_eq!(result.segments.len(), 2);
2178        assert_eq!(result.groups.len(), 2);
2179        assert_eq!(result.groups[0].group_id, "SG2");
2180        assert_eq!(result.groups[0].repetitions.len(), 2);
2181        assert_eq!(result.groups[1].group_id, "SG4");
2182        assert_eq!(result.groups[1].repetitions.len(), 1);
2183    }
2184
2185    #[test]
2186    fn test_variant_groups_with_nested_children() {
2187        // Variant groups can have nested child groups
2188        let sg10 = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
2189        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
2190        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "Z98");
2191
2192        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2193
2194        let segments = vec![
2195            make_owned_seg("UNH", vec![vec!["001"]]),
2196            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2197            make_owned_seg("CCI", vec![vec!["Z30"]]),
2198            make_owned_seg("CAV", vec![vec!["Z91", "Y"]]),
2199            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2200            make_owned_seg("CCI", vec![vec!["Z31"]]),
2201            make_owned_seg("CAV", vec![vec!["Z91", "N"]]),
2202        ];
2203
2204        let assembler = Assembler::new(&mig);
2205        let result = assembler.assemble_generic(&segments).unwrap();
2206
2207        assert_eq!(result.groups.len(), 1);
2208        let sg8 = &result.groups[0];
2209        assert_eq!(sg8.repetitions.len(), 2);
2210
2211        // First rep (ZD7) has nested SG10
2212        assert_eq!(sg8.repetitions[0].child_groups.len(), 1);
2213        assert_eq!(sg8.repetitions[0].child_groups[0].group_id, "SG10");
2214        assert_eq!(
2215            sg8.repetitions[0].child_groups[0].repetitions[0].segments[0].elements[0][0],
2216            "Z30"
2217        );
2218
2219        // Second rep (Z98) has nested SG10
2220        assert_eq!(sg8.repetitions[1].child_groups.len(), 1);
2221        assert_eq!(
2222            sg8.repetitions[1].child_groups[0].repetitions[0].segments[0].elements[0][0],
2223            "Z31"
2224        );
2225    }
2226
2227    #[test]
2228    fn test_variant_qualifier_check_prevents_wrong_variant_consumption() {
2229        // try_consume_group with variant_code set should NOT consume a segment
2230        // whose qualifier doesn't match, even if the tag matches.
2231        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2232
2233        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7]);
2234
2235        let segments = vec![
2236            make_owned_seg("UNH", vec![vec!["001"]]),
2237            make_owned_seg("SEQ", vec![vec!["Z98"]]), // Wrong qualifier
2238            make_owned_seg("CCI", vec![vec!["Z30"]]),
2239        ];
2240
2241        let assembler = Assembler::new(&mig);
2242        let result = assembler.assemble_generic(&segments).unwrap();
2243
2244        // SG8 should have no reps because Z98 != ZD7
2245        assert!(result.groups.is_empty());
2246    }
2247
2248    #[test]
2249    fn test_mixed_variant_and_non_variant_groups() {
2250        // SG2 (no variant), then variant SG8s, then SG12 (no variant)
2251        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
2252        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2253        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2254        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
2255
2256        let mig = make_mig_schema(vec!["UNH"], vec![sg2, sg8_zd7, sg8_z98, sg12]);
2257
2258        let segments = vec![
2259            make_owned_seg("UNH", vec![vec!["001"]]),
2260            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
2261            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2262            make_owned_seg("CCI", vec![vec!["Z30"]]),
2263            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2264            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2265            make_owned_seg("NAD", vec![vec!["Z65", "ID001"]]),
2266        ];
2267
2268        let assembler = Assembler::new(&mig);
2269        let result = assembler.assemble_generic(&segments).unwrap();
2270
2271        assert_eq!(result.groups.len(), 3); // SG2, SG8 (combined), SG12
2272        assert_eq!(result.groups[0].group_id, "SG2");
2273        assert_eq!(result.groups[0].repetitions.len(), 1);
2274        assert_eq!(result.groups[1].group_id, "SG8");
2275        assert_eq!(result.groups[1].repetitions.len(), 2);
2276        assert_eq!(result.groups[2].group_id, "SG12");
2277        assert_eq!(result.groups[2].repetitions.len(), 1);
2278    }
2279
2280    #[test]
2281    fn test_assembler_disambiguates_shared_qualifier_by_full_code_profile() {
2282        // PID 55035 PIA variants: several mig slots share the primary qualifier
2283        // 4347='5' but differ at C212/7143 (one allows Z12, another SRW). With
2284        // only per-mig qualifier_map, the assembler consumes at the first matching
2285        // slot regardless of the composite code — the downstream validator is
2286        // then forced to second-guess the variant choice. Disambiguate at
2287        // assembly time by checking all code-bearing positions declared on the
2288        // MIG segment.
2289        use mig_types::schema::common::CodeDefinition;
2290        use mig_types::schema::mig::{MigComposite, MigDataElement};
2291        use std::collections::HashMap;
2292
2293        fn code(value: &str) -> CodeDefinition {
2294            CodeDefinition {
2295                value: value.to_string(),
2296                name: value.to_string(),
2297                description: None,
2298            }
2299        }
2300
2301        fn pia_slot(number: &str, composite_code: &str) -> MigSegment {
2302            MigSegment {
2303                id: "PIA".to_string(),
2304                name: "PIA".to_string(),
2305                description: None,
2306                counter: None,
2307                level: 1,
2308                number: Some(number.to_string()),
2309                max_rep_std: 1,
2310                max_rep_spec: 1,
2311                status_std: Some("M".to_string()),
2312                status_spec: Some("M".to_string()),
2313                example: None,
2314                data_elements: vec![MigDataElement {
2315                    id: "4347".to_string(),
2316                    name: "Produkt-ID-Funktion".to_string(),
2317                    description: None,
2318                    status_std: Some("M".to_string()),
2319                    status_spec: Some("M".to_string()),
2320                    format_std: None,
2321                    format_spec: None,
2322                    codes: vec![code("5")],
2323                    position: 0,
2324                }],
2325                composites: vec![MigComposite {
2326                    id: "C212".to_string(),
2327                    name: "Item Identifier".to_string(),
2328                    description: None,
2329                    status_std: Some("M".to_string()),
2330                    status_spec: Some("M".to_string()),
2331                    data_elements: vec![MigDataElement {
2332                        id: "7143".to_string(),
2333                        name: "Artikel/Dienstleistung-ID".to_string(),
2334                        description: None,
2335                        status_std: Some("M".to_string()),
2336                        status_spec: Some("M".to_string()),
2337                        format_std: None,
2338                        format_spec: None,
2339                        codes: vec![code(composite_code)],
2340                        position: 0,
2341                    }],
2342                    position: 1,
2343                }],
2344            }
2345        }
2346
2347        let sg4 = MigSegmentGroup {
2348            segments: vec![
2349                crate::test_support::make_mig_segment_numbered("IDE", "00020"),
2350                pia_slot("00108", "Z12"),
2351                pia_slot("00197", "SRW"),
2352            ],
2353            ..make_mig_group("SG4", vec![], vec![])
2354        };
2355        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
2356
2357        let segments = vec![
2358            make_owned_seg("UNH", vec![vec!["001"]]),
2359            make_owned_seg("IDE", vec![vec!["24"]]),
2360            // PIA+5+:::SRW — composite element 1, component 0 = "SRW"
2361            make_owned_seg("PIA", vec![vec!["5"], vec!["SRW"]]),
2362        ];
2363
2364        // Both PIA mig slots share the qualifier (0,0)='5'. Without full-profile
2365        // matching, the first slot (00108) wins and the SRW composite is
2366        // mis-assigned to the Z12 variant.
2367        let mut qualifier_map = HashMap::new();
2368        qualifier_map.insert("00108".to_string(), (0, 0, "5".to_string()));
2369        qualifier_map.insert("00197".to_string(), (0, 0, "5".to_string()));
2370
2371        let config = AssemblerConfig {
2372            skip_unknown_segments: false,
2373            qualifier_map,
2374            strict_code_matching: true,
2375        };
2376        let assembler = Assembler::with_config(&mig, config);
2377        let tree = assembler.assemble_generic(&segments).unwrap();
2378
2379        let sg4_instance = &tree.groups[0].repetitions[0];
2380        let pia = sg4_instance
2381            .segments
2382            .iter()
2383            .find(|s| s.tag == "PIA")
2384            .expect("PIA consumed into SG4");
2385        assert_eq!(
2386            pia.mig_number.as_deref(),
2387            Some("00197"),
2388            "PIA+5+:::SRW must be assigned the SRW variant (mig=00197), not the Z12 variant"
2389        );
2390    }
2391
2392    #[test]
2393    fn a_repeatable_slot_keeps_its_repetitions() {
2394        // UTILMD SG10 CCI+++Z25 (Wandler): CAV+Z30 (00341, up to 3 times) then
2395        // CAV+MIW (00342, once). A Messwandlersatz carries two Gerätenummern
2396        // ([2317]); the second CAV+Z30 used to take the MIW slot, leaving the
2397        // CAV+MIW without a MIG number — and so without any check of its
2398        // Wandlerfaktor (n..35).
2399        use mig_types::schema::common::CodeDefinition;
2400        use mig_types::schema::mig::{MigComposite, MigDataElement};
2401        use std::collections::HashMap;
2402
2403        fn cav_slot(number: &str, codes: &[&str], max_rep_spec: i32) -> MigSegment {
2404            MigSegment {
2405                id: "CAV".to_string(),
2406                name: "CAV".to_string(),
2407                description: None,
2408                counter: None,
2409                level: 1,
2410                number: Some(number.to_string()),
2411                max_rep_std: 99,
2412                max_rep_spec,
2413                status_std: Some("M".to_string()),
2414                status_spec: Some("M".to_string()),
2415                example: None,
2416                data_elements: vec![],
2417                composites: vec![MigComposite {
2418                    id: "C889".to_string(),
2419                    name: "Merkmalswert".to_string(),
2420                    description: None,
2421                    status_std: Some("M".to_string()),
2422                    status_spec: Some("M".to_string()),
2423                    data_elements: vec![MigDataElement {
2424                        id: "7111".to_string(),
2425                        name: "Merkmalswert, Code".to_string(),
2426                        description: None,
2427                        status_std: Some("M".to_string()),
2428                        status_spec: Some("M".to_string()),
2429                        format_std: None,
2430                        format_spec: None,
2431                        codes: codes
2432                            .iter()
2433                            .map(|c| CodeDefinition {
2434                                value: c.to_string(),
2435                                name: c.to_string(),
2436                                description: None,
2437                            })
2438                            .collect(),
2439                        position: 0,
2440                    }],
2441                    position: 0,
2442                }],
2443            }
2444        }
2445
2446        let sg10 = MigSegmentGroup {
2447            segments: vec![
2448                crate::test_support::make_mig_segment_numbered("CCI", "00340"),
2449                cav_slot("00341", &["Z30"], 3),
2450                cav_slot("00342", &["MIW", "MPW", "MBW", "MUW"], 1),
2451            ],
2452            ..make_mig_group("SG10", vec![], vec![])
2453        };
2454        let mig = make_mig_schema(vec!["UNH"], vec![sg10]);
2455        let segments = vec![
2456            make_owned_seg("UNH", vec![vec!["001"]]),
2457            make_owned_seg("CCI", vec![vec![""], vec![""], vec!["Z25"]]),
2458            make_owned_seg("CAV", vec![vec!["Z30", "", "", "W1"]]),
2459            make_owned_seg("CAV", vec![vec!["Z30", "", "", "W2"]]),
2460            make_owned_seg("CAV", vec![vec!["MIW", "", "", "30"]]),
2461        ];
2462        let config = AssemblerConfig {
2463            skip_unknown_segments: false,
2464            qualifier_map: HashMap::new(),
2465            strict_code_matching: true,
2466        };
2467        let tree = Assembler::with_config(&mig, config)
2468            .assemble_generic(&segments)
2469            .unwrap();
2470        let numbers: Vec<Option<&str>> = tree.groups[0].repetitions[0]
2471            .segments
2472            .iter()
2473            .map(|s| s.mig_number.as_deref())
2474            .collect();
2475        assert_eq!(
2476            numbers,
2477            [Some("00340"), Some("00341"), Some("00341"), Some("00342")]
2478        );
2479    }
2480}