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, 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, 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, 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    }
1127    numbers
1128}
1129
1130pub fn owned_to_assembled(seg: &OwnedSegment) -> AssembledSegment {
1131    AssembledSegment {
1132        tag: seg.id.clone(),
1133        elements: seg.elements.clone(),
1134        mig_number: None,
1135        segment_number: Some(seg.segment_number),
1136    }
1137}
1138
1139/// Check every code-bearing position declared on a MIG segment against the
1140/// corresponding value on the input segment.
1141///
1142/// Used by `try_consume_segment` to disambiguate slots that share the same
1143/// primary qualifier but differ on a secondary code. Returns `true` when the
1144/// input segment's values at each declared position are either empty
1145/// (optional) or in the slot's allowed set.
1146fn segment_matches_mig_codes(seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
1147    let actual_at = |el: usize, c: usize| -> &str {
1148        seg.elements
1149            .get(el)
1150            .and_then(|e| e.get(c))
1151            .map(|s| s.as_str())
1152            .unwrap_or("")
1153    };
1154    for de in &mig_seg.data_elements {
1155        if !de.codes.is_empty() {
1156            let actual = actual_at(de.position, 0);
1157            if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
1158                return false;
1159            }
1160        }
1161    }
1162    for comp in &mig_seg.composites {
1163        for de in &comp.data_elements {
1164            if !de.codes.is_empty() {
1165                let actual = actual_at(comp.position, de.position);
1166                if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
1167                    return false;
1168                }
1169            }
1170        }
1171    }
1172    true
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178    use crate::test_support::{make_mig_group, make_mig_group_with_variant, make_mig_segment};
1179
1180    fn make_owned_seg(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
1181        OwnedSegment {
1182            id: id.to_string(),
1183            elements: elements
1184                .into_iter()
1185                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
1186                .collect(),
1187            segment_number: 0,
1188        }
1189    }
1190
1191    fn make_mig_schema(segments: Vec<&str>, groups: Vec<MigSegmentGroup>) -> MigSchema {
1192        MigSchema {
1193            message_type: "UTILMD".to_string(),
1194            variant: Some("Strom".to_string()),
1195            version: "S2.1".to_string(),
1196            publication_date: "2025-03-20".to_string(),
1197            author: "BDEW".to_string(),
1198            format_version: "FV2504".to_string(),
1199            source_file: "test".to_string(),
1200            segments: segments.into_iter().map(make_mig_segment).collect(),
1201            segment_groups: groups,
1202        }
1203    }
1204
1205    #[test]
1206    fn test_assembler_top_level_segments_only() {
1207        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
1208
1209        let segments = vec![
1210            make_owned_seg("UNH", vec![vec!["001", "UTILMD:D:11A:UN:S2.1"]]),
1211            make_owned_seg("BGM", vec![vec!["E01", "DOC001"]]),
1212            make_owned_seg("DTM", vec![vec!["137", "20250101", "102"]]),
1213            make_owned_seg("UNT", vec![vec!["4", "001"]]),
1214        ];
1215
1216        let assembler = Assembler::new(&mig);
1217        let result = assembler.assemble_generic(&segments).unwrap();
1218
1219        assert_eq!(result.segments.len(), 4);
1220        assert_eq!(result.segments[0].tag, "UNH");
1221        assert_eq!(result.segments[1].tag, "BGM");
1222        assert_eq!(result.segments[2].tag, "DTM");
1223        assert_eq!(result.segments[3].tag, "UNT");
1224        assert!(result.groups.is_empty());
1225    }
1226
1227    #[test]
1228    fn test_assembler_with_segment_group() {
1229        let mig = make_mig_schema(
1230            vec!["UNH", "BGM"],
1231            vec![
1232                make_mig_group("SG2", vec!["NAD"], vec![]),
1233                make_mig_group("SG4", vec!["IDE", "STS"], vec![]),
1234            ],
1235        );
1236
1237        let segments = vec![
1238            make_owned_seg("UNH", vec![vec!["001"]]),
1239            make_owned_seg("BGM", vec![vec!["E01"]]),
1240            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1241            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
1242            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1243            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1244        ];
1245
1246        let assembler = Assembler::new(&mig);
1247        let result = assembler.assemble_generic(&segments).unwrap();
1248
1249        // Top-level: UNH, BGM
1250        assert_eq!(result.segments.len(), 2);
1251        // SG2: 2 repetitions (two NAD segments)
1252        assert_eq!(result.groups.len(), 2);
1253        assert_eq!(result.groups[0].group_id, "SG2");
1254        assert_eq!(result.groups[0].repetitions.len(), 2);
1255        assert_eq!(result.groups[0].repetitions[0].segments[0].tag, "NAD");
1256        assert_eq!(result.groups[0].repetitions[1].segments[0].tag, "NAD");
1257        // SG4: 1 repetition (IDE + STS)
1258        assert_eq!(result.groups[1].group_id, "SG4");
1259        assert_eq!(result.groups[1].repetitions.len(), 1);
1260        assert_eq!(result.groups[1].repetitions[0].segments.len(), 2);
1261    }
1262
1263    #[test]
1264    fn test_assembler_nested_groups() {
1265        let sg3 = make_mig_group("SG3", vec!["CTA", "COM"], vec![]);
1266        let mig = make_mig_schema(
1267            vec!["UNH", "BGM"],
1268            vec![make_mig_group("SG2", vec!["NAD"], vec![sg3])],
1269        );
1270
1271        let segments = vec![
1272            make_owned_seg("UNH", vec![vec!["001"]]),
1273            make_owned_seg("BGM", vec![vec!["E01"]]),
1274            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1275            make_owned_seg("CTA", vec![vec!["IC", "Kontakt"]]),
1276            make_owned_seg("COM", vec![vec!["040@example.com", "EM"]]),
1277        ];
1278
1279        let assembler = Assembler::new(&mig);
1280        let result = assembler.assemble_generic(&segments).unwrap();
1281
1282        // SG2 has 1 repetition
1283        let sg2 = &result.groups[0];
1284        assert_eq!(sg2.group_id, "SG2");
1285        assert_eq!(sg2.repetitions.len(), 1);
1286
1287        let sg2_inst = &sg2.repetitions[0];
1288        assert_eq!(sg2_inst.segments[0].tag, "NAD");
1289
1290        // SG3 nested inside SG2
1291        assert_eq!(sg2_inst.child_groups.len(), 1);
1292        let sg3 = &sg2_inst.child_groups[0];
1293        assert_eq!(sg3.group_id, "SG3");
1294        assert_eq!(sg3.repetitions[0].segments.len(), 2);
1295        assert_eq!(sg3.repetitions[0].segments[0].tag, "CTA");
1296        assert_eq!(sg3.repetitions[0].segments[1].tag, "COM");
1297    }
1298
1299    #[test]
1300    fn test_assembler_optional_segments_skipped() {
1301        // MIG expects UNH, BGM, DTM, UNT but input has no DTM
1302        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
1303
1304        let segments = vec![
1305            make_owned_seg("UNH", vec![vec!["001"]]),
1306            make_owned_seg("BGM", vec![vec!["E01"]]),
1307            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1308        ];
1309
1310        let assembler = Assembler::new(&mig);
1311        let result = assembler.assemble_generic(&segments).unwrap();
1312
1313        // DTM is skipped (optional), UNT consumed
1314        assert_eq!(result.segments.len(), 3);
1315        assert_eq!(result.segments[0].tag, "UNH");
1316        assert_eq!(result.segments[1].tag, "BGM");
1317        assert_eq!(result.segments[2].tag, "UNT");
1318    }
1319
1320    #[test]
1321    fn test_assembler_empty_segments() {
1322        let mig = make_mig_schema(vec!["UNH"], vec![]);
1323        let assembler = Assembler::new(&mig);
1324        let result = assembler.assemble_generic(&[]).unwrap();
1325        assert!(result.segments.is_empty());
1326        assert!(result.groups.is_empty());
1327    }
1328
1329    #[test]
1330    fn test_assembler_preserves_element_data() {
1331        let mig = make_mig_schema(vec!["DTM"], vec![]);
1332
1333        let segments = vec![make_owned_seg(
1334            "DTM",
1335            vec![vec!["137", "202501010000+01", "303"]],
1336        )];
1337
1338        let assembler = Assembler::new(&mig);
1339        let result = assembler.assemble_generic(&segments).unwrap();
1340
1341        let dtm = &result.segments[0];
1342        assert_eq!(dtm.elements[0][0], "137");
1343        assert_eq!(dtm.elements[0][1], "202501010000+01");
1344        assert_eq!(dtm.elements[0][2], "303");
1345    }
1346
1347    #[test]
1348    fn test_group_instance_as_assembled_tree() {
1349        // Build an SG4 instance with root segments (IDE, STS) and child groups (SG5)
1350        let sg5 = AssembledGroup {
1351            group_id: "SG5".to_string(),
1352            repetitions: vec![AssembledGroupInstance {
1353                segments: vec![AssembledSegment {
1354                    tag: "LOC".to_string(),
1355                    elements: vec![vec!["Z16".to_string(), "DE000111222333".to_string()]],
1356                    mig_number: None,
1357                    segment_number: None,
1358                }],
1359                child_groups: vec![],
1360                entry_mig_number: None,
1361                variant_mig_numbers: vec![],
1362                skipped_segments: vec![],
1363                skipped_positions: Vec::new(),
1364            }],
1365        };
1366
1367        let sg4_instance = AssembledGroupInstance {
1368            segments: vec![
1369                AssembledSegment {
1370                    tag: "IDE".to_string(),
1371                    elements: vec![vec!["24".to_string(), "TX001".to_string()]],
1372                    mig_number: None,
1373                    segment_number: None,
1374                },
1375                AssembledSegment {
1376                    tag: "STS".to_string(),
1377                    elements: vec![vec!["7".to_string()]],
1378                    mig_number: None,
1379                    segment_number: None,
1380                },
1381            ],
1382            child_groups: vec![sg5],
1383            entry_mig_number: None,
1384            variant_mig_numbers: vec![],
1385            skipped_segments: vec![],
1386            skipped_positions: Vec::new(),
1387        };
1388
1389        let sub_tree = sg4_instance.as_assembled_tree();
1390
1391        // Root segments of sub-tree are the SG4 instance's segments
1392        assert_eq!(sub_tree.segments.len(), 2);
1393        assert_eq!(sub_tree.segments[0].tag, "IDE");
1394        assert_eq!(sub_tree.segments[1].tag, "STS");
1395
1396        // Groups of sub-tree are the SG4 instance's child groups
1397        assert_eq!(sub_tree.groups.len(), 1);
1398        assert_eq!(sub_tree.groups[0].group_id, "SG5");
1399
1400        // post_group_start marks where root segments end
1401        assert_eq!(sub_tree.post_group_start, 2);
1402    }
1403
1404    #[test]
1405    fn test_assembler_from_parsed_edifact() {
1406        // End-to-end: parse raw EDIFACT, then assemble
1407        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'";
1408        let segments = crate::tokenize::parse_to_segments(input).unwrap();
1409
1410        let mig = make_mig_schema(vec!["UNB", "UNH", "BGM", "DTM", "UNT", "UNZ"], vec![]);
1411
1412        let assembler = Assembler::new(&mig);
1413        let result = assembler.assemble_generic(&segments).unwrap();
1414
1415        assert!(result.segments.iter().any(|s| s.tag == "UNH"));
1416        assert!(result.segments.iter().any(|s| s.tag == "BGM"));
1417        assert!(result.segments.iter().any(|s| s.tag == "DTM"));
1418    }
1419
1420    #[test]
1421    fn test_assemble_with_diagnostics_clean_input() {
1422        let mig = make_mig_schema(vec!["UNH", "BGM", "UNT"], vec![]);
1423        let segments = vec![
1424            make_owned_seg("UNH", vec![vec!["001"]]),
1425            make_owned_seg("BGM", vec![vec!["E01"]]),
1426            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1427        ];
1428        let assembler = Assembler::new(&mig);
1429        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1430        assert_eq!(tree.segments.len(), 3);
1431        assert!(
1432            diagnostics.is_empty(),
1433            "Clean input should have no diagnostics"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_assemble_with_diagnostics_unconsumed_segments() {
1439        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![]);
1440        let segments = vec![
1441            make_owned_seg("UNH", vec![vec!["001"]]),
1442            make_owned_seg("BGM", vec![vec!["E01"]]),
1443            make_owned_seg("FTX", vec![vec!["AAA", "extra text"]]),
1444        ];
1445        let assembler = Assembler::new(&mig);
1446        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1447        assert_eq!(tree.segments.len(), 2);
1448        assert_eq!(diagnostics.len(), 1);
1449        assert_eq!(
1450            diagnostics[0].kind,
1451            StructureDiagnosticKind::UnexpectedSegment
1452        );
1453        assert_eq!(diagnostics[0].segment_id, "FTX");
1454        assert_eq!(diagnostics[0].position, 2);
1455    }
1456
1457    #[test]
1458    fn test_assemble_with_diagnostics_multiple_unconsumed() {
1459        let mig = make_mig_schema(vec!["UNH"], vec![]);
1460        let segments = vec![
1461            make_owned_seg("UNH", vec![vec!["001"]]),
1462            make_owned_seg("FOO", vec![]),
1463            make_owned_seg("BAR", vec![]),
1464            make_owned_seg("BAZ", vec![]),
1465        ];
1466        let assembler = Assembler::new(&mig);
1467        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1468        assert_eq!(tree.segments.len(), 1);
1469        assert_eq!(diagnostics.len(), 3);
1470        assert_eq!(diagnostics[0].segment_id, "FOO");
1471        assert_eq!(diagnostics[1].segment_id, "BAR");
1472        assert_eq!(diagnostics[2].segment_id, "BAZ");
1473    }
1474
1475    // ── Non-entry segment mig_number assignment tests ──
1476
1477    #[test]
1478    fn test_non_entry_segments_get_mig_number_from_bounded_slots() {
1479        // MIG group SG4 has entry IDE + two numbered DTMs + STS.
1480        // The assembler should assign mig_number from the MIG slots to
1481        // each non-entry segment via the bounded consumption path.
1482        use crate::test_support::make_mig_segment_numbered;
1483
1484        let sg4 = MigSegmentGroup {
1485            segments: vec![
1486                make_mig_segment_numbered("IDE", "00020"),
1487                make_mig_segment_numbered("DTM", "00023"),
1488                make_mig_segment_numbered("DTM", "00024"),
1489                make_mig_segment_numbered("STS", "00035"),
1490            ],
1491            ..make_mig_group("SG4", vec![], vec![])
1492        };
1493        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1494
1495        let segments = vec![
1496            make_owned_seg("UNH", vec![vec!["001"]]),
1497            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1498            make_owned_seg("DTM", vec![vec!["92", "202505312200+00", "303"]]),
1499            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1500            make_owned_seg("STS", vec![vec!["7"], vec![], vec!["E01"]]),
1501        ];
1502
1503        let assembler = Assembler::new(&mig);
1504        let tree = assembler.assemble_generic(&segments).unwrap();
1505
1506        let sg4_instance = &tree.groups[0].repetitions[0];
1507
1508        // IDE (entry) gets mig_number from try_consume_segment
1509        assert_eq!(sg4_instance.segments[0].tag, "IDE");
1510        assert_eq!(
1511            sg4_instance.segments[0].mig_number.as_deref(),
1512            Some("00020")
1513        );
1514
1515        // DTM+92 gets mig_number "00023" from first DTM slot
1516        assert_eq!(sg4_instance.segments[1].tag, "DTM");
1517        assert_eq!(
1518            sg4_instance.segments[1].mig_number.as_deref(),
1519            Some("00023")
1520        );
1521
1522        // DTM+93 gets mig_number "00024" from second DTM slot
1523        assert_eq!(sg4_instance.segments[2].tag, "DTM");
1524        assert_eq!(
1525            sg4_instance.segments[2].mig_number.as_deref(),
1526            Some("00024")
1527        );
1528
1529        // STS gets mig_number "00035"
1530        assert_eq!(sg4_instance.segments[3].tag, "STS");
1531        assert_eq!(
1532            sg4_instance.segments[3].mig_number.as_deref(),
1533            Some("00035")
1534        );
1535
1536        // variant_mig_numbers should contain all four
1537        assert!(sg4_instance
1538            .variant_mig_numbers
1539            .contains(&"00020".to_string()));
1540        assert!(sg4_instance
1541            .variant_mig_numbers
1542            .contains(&"00023".to_string()));
1543        assert!(sg4_instance
1544            .variant_mig_numbers
1545            .contains(&"00024".to_string()));
1546        assert!(sg4_instance
1547            .variant_mig_numbers
1548            .contains(&"00035".to_string()));
1549    }
1550
1551    #[test]
1552    fn test_greedy_extra_segments_get_no_mig_number() {
1553        // MIG defines 1 DTM slot, but input has 2 DTMs.
1554        // First DTM gets mig_number from bounded path, second gets None (greedy extra).
1555        use crate::test_support::make_mig_segment_numbered;
1556
1557        let sg4 = MigSegmentGroup {
1558            segments: vec![
1559                make_mig_segment_numbered("IDE", "00020"),
1560                make_mig_segment_numbered("DTM", "00023"),
1561            ],
1562            ..make_mig_group("SG4", vec![], vec![])
1563        };
1564        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1565
1566        let segments = vec![
1567            make_owned_seg("UNH", vec![vec!["001"]]),
1568            make_owned_seg("IDE", vec![vec!["24"]]),
1569            make_owned_seg("DTM", vec![vec!["92", "20250531"]]),
1570            make_owned_seg("DTM", vec![vec!["93", "20251231"]]), // extra beyond MIG
1571        ];
1572
1573        let assembler = Assembler::new(&mig);
1574        let tree = assembler.assemble_generic(&segments).unwrap();
1575
1576        let sg4_instance = &tree.groups[0].repetitions[0];
1577        assert_eq!(sg4_instance.segments.len(), 3); // IDE + 2 DTMs
1578
1579        // First DTM: bounded slot → mig_number set
1580        assert_eq!(
1581            sg4_instance.segments[1].mig_number.as_deref(),
1582            Some("00023")
1583        );
1584
1585        // Second DTM: greedy extra → mig_number None
1586        assert_eq!(sg4_instance.segments[2].mig_number, None);
1587    }
1588
1589    // ── Qualifier-aware assembly tests ──
1590
1591    #[test]
1592    fn test_qualifier_map_prevents_wrong_slot_consumption() {
1593        // MIG defines DTM(00023) + DTM(00024). Input has only DTM+93.
1594        // Without qualifier map: DTM+93 consumed by slot 00023 (wrong).
1595        // With qualifier map: slot 00023 expects "92", skips DTM+93.
1596        //   Slot 00024 expects "93", consumes DTM+93 correctly.
1597        use crate::test_support::make_mig_segment_numbered;
1598        use std::collections::HashMap;
1599
1600        let sg4 = MigSegmentGroup {
1601            segments: vec![
1602                make_mig_segment_numbered("IDE", "00020"),
1603                make_mig_segment_numbered("DTM", "00023"),
1604                make_mig_segment_numbered("DTM", "00024"),
1605            ],
1606            ..make_mig_group("SG4", vec![], vec![])
1607        };
1608        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1609
1610        let segments = vec![
1611            make_owned_seg("UNH", vec![vec!["001"]]),
1612            make_owned_seg("IDE", vec![vec!["24"]]),
1613            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1614        ];
1615
1616        let mut qualifier_map = HashMap::new();
1617        qualifier_map.insert("00023".to_string(), (0, 0, "92".to_string()));
1618        qualifier_map.insert("00024".to_string(), (0, 0, "93".to_string()));
1619
1620        let config = AssemblerConfig {
1621            skip_unknown_segments: false,
1622            qualifier_map,
1623            ..Default::default()
1624        };
1625        let assembler = Assembler::with_config(&mig, config);
1626        let tree = assembler.assemble_generic(&segments).unwrap();
1627
1628        let sg4_instance = &tree.groups[0].repetitions[0];
1629
1630        // DTM+93 should be consumed by slot 00024, NOT slot 00023
1631        assert_eq!(sg4_instance.segments.len(), 2); // IDE + DTM+93
1632        let dtm = &sg4_instance.segments[1];
1633        assert_eq!(dtm.tag, "DTM");
1634        assert_eq!(
1635            dtm.mig_number.as_deref(),
1636            Some("00024"),
1637            "DTM+93 should get mig_number 00024 (not 00023)"
1638        );
1639    }
1640
1641    #[test]
1642    fn test_group_entry_qualifier_mismatch_does_not_infinite_loop() {
1643        // Regression: when a group's entry segment has a qualifier_map entry
1644        // but the input segment's qualifier does not match, the outer
1645        // `while !cursor.is_exhausted()` loop in try_consume_group used to
1646        // spin forever — entry tag matched, so the loop kept going, but
1647        // try_consume_segment rejected the segment on qualifier mismatch, so
1648        // the cursor never advanced. Each iteration allocated a fresh
1649        // variant_mig_numbers Vec via collect_mig_numbers, driving unbounded
1650        // memory growth (observed: 3.5 GB → OOM in ~3 s on staging for
1651        // FV2604/UTILMD_Gas/PID 44004 with LOC+172).
1652        use crate::test_support::make_mig_segment_numbered;
1653        use std::collections::HashMap;
1654
1655        let sg5 = MigSegmentGroup {
1656            segments: vec![make_mig_segment_numbered("LOC", "00050")],
1657            ..make_mig_group("SG5", vec![], vec![])
1658        };
1659        let mig = make_mig_schema(vec!["UNH"], vec![sg5]);
1660
1661        let segments = vec![
1662            make_owned_seg("UNH", vec![vec!["001"]]),
1663            // LOC entry tag matches, but qualifier "172" ≠ expected "Z16"
1664            make_owned_seg("LOC", vec![vec!["172"], vec!["92003964705"]]),
1665        ];
1666
1667        let mut qualifier_map = HashMap::new();
1668        qualifier_map.insert("00050".to_string(), (0, 0, "Z16".to_string()));
1669
1670        let config = AssemblerConfig {
1671            skip_unknown_segments: false,
1672            qualifier_map,
1673            ..Default::default()
1674        };
1675        let assembler = Assembler::with_config(&mig, config);
1676
1677        // Before the fix this would loop forever. Bound the assertion with a
1678        // generous wall-clock guard so a regression is a clear test failure
1679        // rather than a hanging CI job.
1680        let start = std::time::Instant::now();
1681        let tree = assembler.assemble_generic(&segments).unwrap();
1682        assert!(
1683            start.elapsed() < std::time::Duration::from_secs(5),
1684            "assembly took {:?} — suspected infinite-loop regression",
1685            start.elapsed()
1686        );
1687
1688        // LOC+172 didn't match SG5's qualifier, so SG5 should be empty.
1689        // The LOC segment remains unconsumed (caller will surface it as a
1690        // structure diagnostic).
1691        assert!(tree.groups.is_empty());
1692    }
1693
1694    // ── Skip-unknown-segments tests ──
1695
1696    #[test]
1697    fn test_skip_unknown_segment_between_slots() {
1698        // MIG group expects [SEQ, CCI], input has [SEQ, RFF, CCI].
1699        // With skip ON, RFF is skipped and CCI is consumed.
1700        // With skip OFF (default), CCI is lost because RFF stalls the cursor.
1701        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1702        let mig = make_mig_schema(vec!["UNH"], vec![sg8.clone()]);
1703
1704        let segments = vec![
1705            make_owned_seg("UNH", vec![vec!["001"]]),
1706            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1707            make_owned_seg("RFF", vec![vec!["Z38", "CROSSREF"]]),
1708            make_owned_seg("CCI", vec![vec!["Z30"]]),
1709        ];
1710
1711        // Skip OFF: CCI not consumed (RFF stalls cursor after SEQ)
1712        let off = Assembler::new(&mig);
1713        let tree_off = off.assemble_generic(&segments).unwrap();
1714        let sg8_off = &tree_off.groups[0];
1715        assert_eq!(sg8_off.repetitions[0].segments.len(), 1); // Only SEQ
1716        assert_eq!(sg8_off.repetitions[0].segments[0].tag, "SEQ");
1717
1718        // Skip ON: RFF skipped, CCI consumed
1719        let on = Assembler::with_config(
1720            &mig,
1721            AssemblerConfig {
1722                skip_unknown_segments: true,
1723                ..Default::default()
1724            },
1725        );
1726        let tree_on = on.assemble_generic(&segments).unwrap();
1727        let sg8_on = &tree_on.groups[0];
1728        assert_eq!(sg8_on.repetitions[0].segments.len(), 2); // SEQ + CCI
1729        assert_eq!(sg8_on.repetitions[0].segments[0].tag, "SEQ");
1730        assert_eq!(sg8_on.repetitions[0].segments[1].tag, "CCI");
1731    }
1732
1733    #[test]
1734    fn test_skip_preserves_on_instance() {
1735        // Skipped segments are stored in instance.skipped_segments
1736        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1737        let mig = make_mig_schema(vec!["UNH"], vec![sg8]);
1738
1739        let segments = vec![
1740            make_owned_seg("UNH", vec![vec!["001"]]),
1741            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1742            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1743            make_owned_seg("DTM", vec![vec!["92", "20250101"]]),
1744            make_owned_seg("CCI", vec![vec!["Z30"]]),
1745        ];
1746
1747        let assembler = Assembler::with_config(
1748            &mig,
1749            AssemblerConfig {
1750                skip_unknown_segments: true,
1751                ..Default::default()
1752            },
1753        );
1754        let tree = assembler.assemble_generic(&segments).unwrap();
1755        let instance = &tree.groups[0].repetitions[0];
1756
1757        assert_eq!(instance.segments.len(), 2); // SEQ + CCI
1758        assert_eq!(instance.skipped_segments.len(), 2); // RFF + DTM
1759        assert_eq!(instance.skipped_segments[0].tag, "RFF");
1760        assert_eq!(instance.skipped_segments[1].tag, "DTM");
1761    }
1762
1763    #[test]
1764    fn test_skip_mode_off_default() {
1765        // Assembler::new() doesn't skip (backwards compat)
1766        let mig = make_mig_schema(vec![], vec![]);
1767        let assembler = Assembler::new(&mig);
1768        assert!(!assembler.config.skip_unknown_segments);
1769    }
1770
1771    #[test]
1772    fn test_skip_does_not_consume_nested_group_entry() {
1773        // Skip must NOT consume segments that are nested group entries.
1774        // SG4 expects [IDE, STS], nested SG5 expects [LOC].
1775        // Input: IDE, FOO, STS, LOC. FOO should be skipped, LOC goes to SG5.
1776        let sg5 = make_mig_group("SG5", vec!["LOC"], vec![]);
1777        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg5]);
1778        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1779
1780        let segments = vec![
1781            make_owned_seg("UNH", vec![vec!["001"]]),
1782            make_owned_seg("IDE", vec![vec!["24"]]),
1783            make_owned_seg("FOO", vec![vec!["unknown"]]),
1784            make_owned_seg("STS", vec![vec!["7"]]),
1785            make_owned_seg("LOC", vec![vec!["Z16"]]),
1786        ];
1787
1788        let assembler = Assembler::with_config(
1789            &mig,
1790            AssemblerConfig {
1791                skip_unknown_segments: true,
1792                ..Default::default()
1793            },
1794        );
1795        let tree = assembler.assemble_generic(&segments).unwrap();
1796        let sg4 = &tree.groups[0];
1797        let inst = &sg4.repetitions[0];
1798
1799        // IDE + STS consumed, FOO skipped
1800        assert_eq!(inst.segments.len(), 2);
1801        assert_eq!(inst.segments[0].tag, "IDE");
1802        assert_eq!(inst.segments[1].tag, "STS");
1803        assert_eq!(inst.skipped_segments.len(), 1);
1804        assert_eq!(inst.skipped_segments[0].tag, "FOO");
1805
1806        // LOC went to nested SG5
1807        assert_eq!(inst.child_groups.len(), 1);
1808        assert_eq!(inst.child_groups[0].group_id, "SG5");
1809        assert_eq!(inst.child_groups[0].repetitions[0].segments[0].tag, "LOC");
1810    }
1811
1812    /// #103: SG10 rendered as `CAV` without its entry segment `CCI`. CAV is
1813    /// defined in the MIG, so the diagnostic must not claim it is unknown — it
1814    /// must say the group's entry segment is missing, since that is what the
1815    /// sender has to fix and what made the group's content disappear.
1816    #[test]
1817    fn test_diagnostics_orphaned_group_segment_without_entry() {
1818        let sg10 = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
1819        let sg8 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "Z03");
1820        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1821        let sg4 = make_mig_group("SG4", vec!["IDE"], vec![sg8, sg12]);
1822        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1823
1824        let segments = vec![
1825            make_owned_seg("UNH", vec![vec!["001"]]),
1826            make_owned_seg("IDE", vec![vec!["24"]]),
1827            make_owned_seg("SEQ", vec![vec!["Z03"]]),
1828            make_owned_seg("CAV", vec![vec!["Z30", "", "", "X"]]),
1829            make_owned_seg("FOO", vec![vec!["unknown"]]),
1830            make_owned_seg("NAD", vec![vec!["Z07"]]),
1831        ];
1832
1833        let assembler = Assembler::with_config(
1834            &mig,
1835            AssemblerConfig {
1836                skip_unknown_segments: true,
1837                ..Default::default()
1838            },
1839        );
1840        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1841
1842        // The NAD after the orphan still assembles.
1843        let sg4 = &tree.groups[0].repetitions[0];
1844        assert!(sg4.child_groups.iter().any(|g| g.group_id == "SG12"));
1845
1846        let orphaned: Vec<_> = diagnostics
1847            .iter()
1848            .filter(|d| d.kind == StructureDiagnosticKind::OrphanedGroupSegment)
1849            .collect();
1850        assert_eq!(orphaned.len(), 1, "{diagnostics:?}");
1851        assert_eq!(orphaned[0].segment_id, "CAV");
1852        assert_eq!(orphaned[0].position, 3);
1853        assert!(
1854            orphaned[0].message.contains("SG10") && orphaned[0].message.contains("'CCI'"),
1855            "message must name the group and its missing entry segment: {}",
1856            orphaned[0].message
1857        );
1858        assert!(
1859            !orphaned[0].message.contains("not defined"),
1860            "CAV is defined in the MIG: {}",
1861            orphaned[0].message
1862        );
1863
1864        // A segment the MIG really does not define keeps its own kind.
1865        let skipped: Vec<_> = diagnostics
1866            .iter()
1867            .filter(|d| d.kind == StructureDiagnosticKind::SkippedUnknownSegment)
1868            .collect();
1869        assert_eq!(skipped.len(), 1, "{diagnostics:?}");
1870        assert_eq!(skipped[0].segment_id, "FOO");
1871    }
1872
1873    #[test]
1874    fn test_skip_unknown_between_nested_group_reps() {
1875        // PID 55035 regression: an AHB-foreign segment sitting between two
1876        // reps of a nested variant-aware group currently stalls the cursor
1877        // and cascades: every subsequent valid rep is lost. With skip mode on,
1878        // the orphan should be recorded and the following reps consumed.
1879        //
1880        // Shape:
1881        //   SG4 [IDE, STS]
1882        //     SG8 (variant ZD7) [SEQ]
1883        //       SG10 [CCI]
1884        //     SG8 (variant Z98) [SEQ]
1885        //     SG12 [NAD]
1886        //
1887        // Input:
1888        //   IDE, STS, SEQ+ZD7, CCI+Z30, FOO+<orphan>, SEQ+Z98, NAD+MS
1889        //
1890        // Expected with skip ON: SG8 has 2 reps (ZD7 + Z98), SG12 has 1 rep,
1891        // and the orphan FOO sits in SG4's skipped_segments.
1892        let sg10 = make_mig_group("SG10", vec!["CCI"], vec![]);
1893        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
1894        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![], "Z98");
1895        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1896        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg8_zd7, sg8_z98, sg12]);
1897        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1898
1899        let segments = vec![
1900            make_owned_seg("UNH", vec![vec!["001"]]),
1901            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1902            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1903            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1904            make_owned_seg("CCI", vec![vec!["Z30"]]),
1905            make_owned_seg("FOO", vec![vec!["orphan"]]),
1906            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1907            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1908        ];
1909
1910        let assembler = Assembler::with_config(
1911            &mig,
1912            AssemblerConfig {
1913                skip_unknown_segments: true,
1914                ..Default::default()
1915            },
1916        );
1917        let tree = assembler.assemble_generic(&segments).unwrap();
1918
1919        assert_eq!(tree.groups.len(), 1);
1920        let sg4_inst = &tree.groups[0].repetitions[0];
1921        // SG4 nested child groups: one SG8 (combined ZD7+Z98) and one SG12
1922        assert_eq!(sg4_inst.child_groups.len(), 2, "expected SG8 + SG12");
1923        let sg8_tree = &sg4_inst.child_groups[0];
1924        assert_eq!(sg8_tree.group_id, "SG8");
1925        assert_eq!(
1926            sg8_tree.repetitions.len(),
1927            2,
1928            "SG8 should have both ZD7 and Z98 reps after orphan skip"
1929        );
1930        assert_eq!(sg8_tree.repetitions[0].segments[0].elements[0][0], "ZD7");
1931        assert_eq!(sg8_tree.repetitions[1].segments[0].elements[0][0], "Z98");
1932
1933        let sg12_tree = &sg4_inst.child_groups[1];
1934        assert_eq!(sg12_tree.group_id, "SG12");
1935        assert_eq!(sg12_tree.repetitions.len(), 1);
1936
1937        // Orphan FOO is recorded on whichever instance was active when it
1938        // was encountered. It may live on SG4, on the first SG8 rep, or on
1939        // that rep's child SG10 — the important thing is that it's captured
1940        // exactly once and the subsequent valid reps were still consumed.
1941        fn count_foo(inst: &AssembledGroupInstance) -> usize {
1942            let mut n = inst
1943                .skipped_segments
1944                .iter()
1945                .filter(|s| s.tag == "FOO")
1946                .count();
1947            for child in &inst.child_groups {
1948                for rep in &child.repetitions {
1949                    n += count_foo(rep);
1950                }
1951            }
1952            n
1953        }
1954        assert_eq!(
1955            count_foo(sg4_inst),
1956            1,
1957            "FOO should be recorded exactly once"
1958        );
1959    }
1960
1961    #[test]
1962    fn test_skip_off_preserves_cascade_behavior() {
1963        // Same structure as above, but with skip OFF the orphan must still
1964        // stall the cursor (callers relying on strict assembly shouldn't
1965        // suddenly see orphans silently swallowed).
1966        let sg10 = make_mig_group("SG10", vec!["CCI"], vec![]);
1967        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "ZD7");
1968        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![], "Z98");
1969        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1970        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg8_zd7, sg8_z98, sg12]);
1971        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1972
1973        let segments = vec![
1974            make_owned_seg("UNH", vec![vec!["001"]]),
1975            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1976            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1977            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1978            make_owned_seg("CCI", vec![vec!["Z30"]]),
1979            make_owned_seg("FOO", vec![vec!["orphan"]]),
1980            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1981            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1982        ];
1983
1984        let assembler = Assembler::new(&mig);
1985        let tree = assembler.assemble_generic(&segments).unwrap();
1986
1987        let sg4_inst = &tree.groups[0].repetitions[0];
1988        let sg8_tree = sg4_inst
1989            .child_groups
1990            .iter()
1991            .find(|g| g.group_id == "SG8")
1992            .expect("SG8 should still be present");
1993        // Only the first SG8 rep gets consumed; Z98 and NAD stall behind FOO.
1994        assert_eq!(sg8_tree.repetitions.len(), 1);
1995        assert!(
1996            sg4_inst.skipped_segments.iter().all(|s| s.tag != "FOO"),
1997            "FOO must not be skipped when skip mode is off"
1998        );
1999    }
2000
2001    #[test]
2002    fn test_roundtrip_with_skip() {
2003        // Full roundtrip: assemble with skip → disassemble → byte-identical
2004        // including skipped segments in the output.
2005        use crate::disassembler::Disassembler;
2006        use crate::renderer::render_edifact;
2007
2008        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
2009        let mig = make_mig_schema(vec!["UNH", "UNT"], vec![sg8]);
2010
2011        let segments = vec![
2012            make_owned_seg("UNH", vec![vec!["001"]]),
2013            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2014            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2015            make_owned_seg("CCI", vec![vec!["Z30"]]),
2016            make_owned_seg("UNT", vec![vec!["4", "001"]]),
2017        ];
2018
2019        let assembler = Assembler::with_config(
2020            &mig,
2021            AssemblerConfig {
2022                skip_unknown_segments: true,
2023                ..Default::default()
2024            },
2025        );
2026        let tree = assembler.assemble_generic(&segments).unwrap();
2027
2028        let disassembler = Disassembler::new(&mig);
2029        let dis = disassembler.disassemble(&tree);
2030        let delimiters = edifact_primitives::EdifactDelimiters::default();
2031        let rendered = render_edifact(&dis, &delimiters);
2032
2033        // All 5 segments should appear in output (including skipped RFF).
2034        // Disassembler emits MIG-guided segments first (SEQ, CCI),
2035        // then skipped segments (RFF) — so order within the group differs
2036        // from the original input, but all content is preserved.
2037        assert_eq!(dis.len(), 5);
2038        assert_eq!(dis[0].tag, "UNH");
2039        assert_eq!(dis[1].tag, "SEQ");
2040        assert_eq!(dis[2].tag, "CCI");
2041        assert_eq!(dis[3].tag, "RFF"); // skipped → emitted after MIG segments
2042        assert_eq!(dis[4].tag, "UNT");
2043
2044        // Rendered output contains all segments
2045        assert!(rendered.contains("UNH+001"));
2046        assert!(rendered.contains("SEQ+Z98"));
2047        assert!(rendered.contains("RFF+Z38:REF1"));
2048        assert!(rendered.contains("CCI+Z30"));
2049        assert!(rendered.contains("UNT+4:001"));
2050    }
2051
2052    // ── Variant-aware assembly tests ──
2053
2054    #[test]
2055    fn test_variant_groups_interleaved_reps() {
2056        // Two SG8 variant definitions: one for SEQ+ZD7, one for SEQ+Z98.
2057        // Input has interleaved reps: ZD7, Z98, ZD7, Z98.
2058        // All should be collected into one SG8 group with 4 reps.
2059        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2060        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2061
2062        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2063
2064        let segments = vec![
2065            make_owned_seg("UNH", vec![vec!["001"]]),
2066            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2067            make_owned_seg("CCI", vec![vec!["Z30"]]),
2068            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2069            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2070            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2071            make_owned_seg("CCI", vec![vec!["Z31"]]),
2072            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2073            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
2074        ];
2075
2076        let assembler = Assembler::new(&mig);
2077        let result = assembler.assemble_generic(&segments).unwrap();
2078
2079        assert_eq!(result.segments.len(), 1); // UNH
2080        assert_eq!(result.groups.len(), 1); // One combined SG8
2081        let sg8 = &result.groups[0];
2082        assert_eq!(sg8.group_id, "SG8");
2083        assert_eq!(sg8.repetitions.len(), 4);
2084
2085        // ZD7 reps have SEQ+CCI, Z98 reps have SEQ+RFF
2086        assert_eq!(sg8.repetitions[0].segments[0].elements[0][0], "ZD7");
2087        assert_eq!(sg8.repetitions[0].segments[1].tag, "CCI");
2088        assert_eq!(sg8.repetitions[1].segments[0].elements[0][0], "Z98");
2089        assert_eq!(sg8.repetitions[1].segments[1].tag, "RFF");
2090        assert_eq!(sg8.repetitions[2].segments[0].elements[0][0], "ZD7");
2091        assert_eq!(sg8.repetitions[3].segments[0].elements[0][0], "Z98");
2092    }
2093
2094    #[test]
2095    fn test_variant_groups_single_variant_type() {
2096        // Only Z98 reps, no ZD7 — still works with variant matching
2097        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2098        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2099
2100        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2101
2102        let segments = vec![
2103            make_owned_seg("UNH", vec![vec!["001"]]),
2104            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2105            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2106            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2107            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
2108        ];
2109
2110        let assembler = Assembler::new(&mig);
2111        let result = assembler.assemble_generic(&segments).unwrap();
2112
2113        assert_eq!(result.groups.len(), 1);
2114        assert_eq!(result.groups[0].repetitions.len(), 2);
2115        assert_eq!(
2116            result.groups[0].repetitions[0].segments[0].elements[0][0],
2117            "Z98"
2118        );
2119        assert_eq!(
2120            result.groups[0].repetitions[1].segments[0].elements[0][0],
2121            "Z98"
2122        );
2123    }
2124
2125    #[test]
2126    fn test_non_variant_groups_unchanged() {
2127        // Groups without variant_code behave exactly as before
2128        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
2129        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![]);
2130
2131        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![sg2, sg4]);
2132
2133        let segments = vec![
2134            make_owned_seg("UNH", vec![vec!["001"]]),
2135            make_owned_seg("BGM", vec![vec!["E01"]]),
2136            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
2137            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
2138            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
2139            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
2140        ];
2141
2142        let assembler = Assembler::new(&mig);
2143        let result = assembler.assemble_generic(&segments).unwrap();
2144
2145        assert_eq!(result.segments.len(), 2);
2146        assert_eq!(result.groups.len(), 2);
2147        assert_eq!(result.groups[0].group_id, "SG2");
2148        assert_eq!(result.groups[0].repetitions.len(), 2);
2149        assert_eq!(result.groups[1].group_id, "SG4");
2150        assert_eq!(result.groups[1].repetitions.len(), 1);
2151    }
2152
2153    #[test]
2154    fn test_variant_groups_with_nested_children() {
2155        // Variant groups can have nested child groups
2156        let sg10 = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
2157        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
2158        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "Z98");
2159
2160        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
2161
2162        let segments = vec![
2163            make_owned_seg("UNH", vec![vec!["001"]]),
2164            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2165            make_owned_seg("CCI", vec![vec!["Z30"]]),
2166            make_owned_seg("CAV", vec![vec!["Z91", "Y"]]),
2167            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2168            make_owned_seg("CCI", vec![vec!["Z31"]]),
2169            make_owned_seg("CAV", vec![vec!["Z91", "N"]]),
2170        ];
2171
2172        let assembler = Assembler::new(&mig);
2173        let result = assembler.assemble_generic(&segments).unwrap();
2174
2175        assert_eq!(result.groups.len(), 1);
2176        let sg8 = &result.groups[0];
2177        assert_eq!(sg8.repetitions.len(), 2);
2178
2179        // First rep (ZD7) has nested SG10
2180        assert_eq!(sg8.repetitions[0].child_groups.len(), 1);
2181        assert_eq!(sg8.repetitions[0].child_groups[0].group_id, "SG10");
2182        assert_eq!(
2183            sg8.repetitions[0].child_groups[0].repetitions[0].segments[0].elements[0][0],
2184            "Z30"
2185        );
2186
2187        // Second rep (Z98) has nested SG10
2188        assert_eq!(sg8.repetitions[1].child_groups.len(), 1);
2189        assert_eq!(
2190            sg8.repetitions[1].child_groups[0].repetitions[0].segments[0].elements[0][0],
2191            "Z31"
2192        );
2193    }
2194
2195    #[test]
2196    fn test_variant_qualifier_check_prevents_wrong_variant_consumption() {
2197        // try_consume_group with variant_code set should NOT consume a segment
2198        // whose qualifier doesn't match, even if the tag matches.
2199        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2200
2201        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7]);
2202
2203        let segments = vec![
2204            make_owned_seg("UNH", vec![vec!["001"]]),
2205            make_owned_seg("SEQ", vec![vec!["Z98"]]), // Wrong qualifier
2206            make_owned_seg("CCI", vec![vec!["Z30"]]),
2207        ];
2208
2209        let assembler = Assembler::new(&mig);
2210        let result = assembler.assemble_generic(&segments).unwrap();
2211
2212        // SG8 should have no reps because Z98 != ZD7
2213        assert!(result.groups.is_empty());
2214    }
2215
2216    #[test]
2217    fn test_mixed_variant_and_non_variant_groups() {
2218        // SG2 (no variant), then variant SG8s, then SG12 (no variant)
2219        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
2220        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
2221        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
2222        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
2223
2224        let mig = make_mig_schema(vec!["UNH"], vec![sg2, sg8_zd7, sg8_z98, sg12]);
2225
2226        let segments = vec![
2227            make_owned_seg("UNH", vec![vec!["001"]]),
2228            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
2229            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
2230            make_owned_seg("CCI", vec![vec!["Z30"]]),
2231            make_owned_seg("SEQ", vec![vec!["Z98"]]),
2232            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
2233            make_owned_seg("NAD", vec![vec!["Z65", "ID001"]]),
2234        ];
2235
2236        let assembler = Assembler::new(&mig);
2237        let result = assembler.assemble_generic(&segments).unwrap();
2238
2239        assert_eq!(result.groups.len(), 3); // SG2, SG8 (combined), SG12
2240        assert_eq!(result.groups[0].group_id, "SG2");
2241        assert_eq!(result.groups[0].repetitions.len(), 1);
2242        assert_eq!(result.groups[1].group_id, "SG8");
2243        assert_eq!(result.groups[1].repetitions.len(), 2);
2244        assert_eq!(result.groups[2].group_id, "SG12");
2245        assert_eq!(result.groups[2].repetitions.len(), 1);
2246    }
2247
2248    #[test]
2249    fn test_assembler_disambiguates_shared_qualifier_by_full_code_profile() {
2250        // PID 55035 PIA variants: several mig slots share the primary qualifier
2251        // 4347='5' but differ at C212/7143 (one allows Z12, another SRW). With
2252        // only per-mig qualifier_map, the assembler consumes at the first matching
2253        // slot regardless of the composite code — the downstream validator is
2254        // then forced to second-guess the variant choice. Disambiguate at
2255        // assembly time by checking all code-bearing positions declared on the
2256        // MIG segment.
2257        use mig_types::schema::common::CodeDefinition;
2258        use mig_types::schema::mig::{MigComposite, MigDataElement};
2259        use std::collections::HashMap;
2260
2261        fn code(value: &str) -> CodeDefinition {
2262            CodeDefinition {
2263                value: value.to_string(),
2264                name: value.to_string(),
2265                description: None,
2266            }
2267        }
2268
2269        fn pia_slot(number: &str, composite_code: &str) -> MigSegment {
2270            MigSegment {
2271                id: "PIA".to_string(),
2272                name: "PIA".to_string(),
2273                description: None,
2274                counter: None,
2275                level: 1,
2276                number: Some(number.to_string()),
2277                max_rep_std: 1,
2278                max_rep_spec: 1,
2279                status_std: Some("M".to_string()),
2280                status_spec: Some("M".to_string()),
2281                example: None,
2282                data_elements: vec![MigDataElement {
2283                    id: "4347".to_string(),
2284                    name: "Produkt-ID-Funktion".to_string(),
2285                    description: None,
2286                    status_std: Some("M".to_string()),
2287                    status_spec: Some("M".to_string()),
2288                    format_std: None,
2289                    format_spec: None,
2290                    codes: vec![code("5")],
2291                    position: 0,
2292                }],
2293                composites: vec![MigComposite {
2294                    id: "C212".to_string(),
2295                    name: "Item Identifier".to_string(),
2296                    description: None,
2297                    status_std: Some("M".to_string()),
2298                    status_spec: Some("M".to_string()),
2299                    data_elements: vec![MigDataElement {
2300                        id: "7143".to_string(),
2301                        name: "Artikel/Dienstleistung-ID".to_string(),
2302                        description: None,
2303                        status_std: Some("M".to_string()),
2304                        status_spec: Some("M".to_string()),
2305                        format_std: None,
2306                        format_spec: None,
2307                        codes: vec![code(composite_code)],
2308                        position: 0,
2309                    }],
2310                    position: 1,
2311                }],
2312            }
2313        }
2314
2315        let sg4 = MigSegmentGroup {
2316            segments: vec![
2317                crate::test_support::make_mig_segment_numbered("IDE", "00020"),
2318                pia_slot("00108", "Z12"),
2319                pia_slot("00197", "SRW"),
2320            ],
2321            ..make_mig_group("SG4", vec![], vec![])
2322        };
2323        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
2324
2325        let segments = vec![
2326            make_owned_seg("UNH", vec![vec!["001"]]),
2327            make_owned_seg("IDE", vec![vec!["24"]]),
2328            // PIA+5+:::SRW — composite element 1, component 0 = "SRW"
2329            make_owned_seg("PIA", vec![vec!["5"], vec!["SRW"]]),
2330        ];
2331
2332        // Both PIA mig slots share the qualifier (0,0)='5'. Without full-profile
2333        // matching, the first slot (00108) wins and the SRW composite is
2334        // mis-assigned to the Z12 variant.
2335        let mut qualifier_map = HashMap::new();
2336        qualifier_map.insert("00108".to_string(), (0, 0, "5".to_string()));
2337        qualifier_map.insert("00197".to_string(), (0, 0, "5".to_string()));
2338
2339        let config = AssemblerConfig {
2340            skip_unknown_segments: false,
2341            qualifier_map,
2342            strict_code_matching: true,
2343        };
2344        let assembler = Assembler::with_config(&mig, config);
2345        let tree = assembler.assemble_generic(&segments).unwrap();
2346
2347        let sg4_instance = &tree.groups[0].repetitions[0];
2348        let pia = sg4_instance
2349            .segments
2350            .iter()
2351            .find(|s| s.tag == "PIA")
2352            .expect("PIA consumed into SG4");
2353        assert_eq!(
2354            pia.mig_number.as_deref(),
2355            Some("00197"),
2356            "PIA+5+:::SRW must be assigned the SRW variant (mig=00197), not the Z12 variant"
2357        );
2358    }
2359}