Skip to main content

mig_assembly/
assembler.rs

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