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