Skip to main content

mig_assembly/
assembler.rs

1//! Recursive descent assembler — MIG-guided segment consumption.
2//!
3//! The assembler walks the MIG tree structure and consumes matching
4//! segments from the input. It produces a generic tree representation
5//! that can be converted to typed PID structs.
6
7use crate::cursor::SegmentCursor;
8use crate::diagnostic::{StructureDiagnostic, StructureDiagnosticKind};
9use crate::matcher;
10use crate::tokenize::OwnedSegment;
11use crate::AssemblyError;
12use mig_types::schema::mig::{MigSchema, MigSegment, MigSegmentGroup};
13use serde::{Deserialize, Serialize};
14
15/// A generic assembled tree node (before PID-specific typing).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AssembledTree {
18    pub segments: Vec<AssembledSegment>,
19    pub groups: Vec<AssembledGroup>,
20    /// Index in `segments` where post-group segments start (e.g., UNT, UNZ).
21    /// Segments before this index appear before groups in EDIFACT order.
22    #[serde(default)]
23    pub post_group_start: usize,
24    /// Root segments consumed between groups during assembly (e.g., UNS
25    /// section separator in MSCONS). Key = index into `groups` vec; value =
26    /// segments that appear immediately before that group in the EDIFACT
27    /// stream. Empty for messages without inter-group root segments.
28    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
29    pub inter_group_segments: std::collections::BTreeMap<usize, Vec<AssembledSegment>>,
30}
31
32/// An assembled segment with its data elements.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct AssembledSegment {
35    pub tag: String,
36    /// `elements[i][j]` = component `j` of element `i`
37    pub elements: Vec<Vec<String>>,
38    /// MIG `Number` attribute identifying this segment variant.
39    /// Two segments with the same tag (e.g., DTM) but different roles
40    /// (DTM+92 vs DTM+93) have distinct MIG numbers.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub mig_number: Option<String>,
43}
44
45/// An assembled segment group (may repeat).
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct AssembledGroup {
48    pub group_id: String,
49    pub repetitions: Vec<AssembledGroupInstance>,
50}
51
52/// One repetition of a segment group.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AssembledGroupInstance {
55    pub segments: Vec<AssembledSegment>,
56    pub child_groups: Vec<AssembledGroup>,
57    /// MIG `Number` of the entry segment that identified this group instance's variant.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub entry_mig_number: Option<String>,
60    /// All MIG `Number`s defined for this group variant — includes segments that
61    /// may be absent in the EDIFACT but are defined in the MIG for this variant.
62    ///
63    /// Used by the validator to determine which AHB rules belong to this instance:
64    /// a rule with `mig_number` in this set applies here, even if the segment is
65    /// missing (which is then a missing-field error). Without this, rules for
66    /// absent-but-required segments would be incorrectly filtered out.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub variant_mig_numbers: Vec<String>,
69    /// Segments that were present in the EDIFACT input but not defined in
70    /// the PID-filtered MIG for this group. Only populated when the assembler
71    /// runs with [`AssemblerConfig::skip_unknown_segments`] enabled.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub skipped_segments: Vec<AssembledSegment>,
74}
75
76impl AssembledGroupInstance {
77    /// Create a virtual `AssembledTree` scoped to this group instance.
78    ///
79    /// The instance's own segments become the tree's root segments,
80    /// and its child groups become the tree's groups. This enables
81    /// running `MappingEngine::map_all_forward()` on a single
82    /// transaction group as if it were a complete message.
83    pub fn as_assembled_tree(&self) -> AssembledTree {
84        AssembledTree {
85            segments: self.segments.clone(),
86            groups: self.child_groups.clone(),
87            post_group_start: self.segments.len(),
88            inter_group_segments: std::collections::BTreeMap::new(),
89        }
90    }
91}
92
93/// Configuration for the assembler.
94#[derive(Debug, Clone, Default)]
95pub struct AssemblerConfig {
96    /// When `true`, the assembler skips segments inside a group instance that
97    /// don't match any remaining MIG slot, nested-group entry, or the group's
98    /// entry tag (next repetition). Skipped segments are preserved on
99    /// [`AssembledGroupInstance::skipped_segments`] for roundtrip re-emission.
100    ///
101    /// Default: `false` (strict AHB — unknown segments stall the cursor).
102    pub skip_unknown_segments: bool,
103
104    /// Qualifier-aware assembly: maps MIG `Number` to `(element_index, component_index, expected_value)`.
105    ///
106    /// When a bounded slot has a `number` with an entry in this map,
107    /// `try_consume_segment` checks the input segment's value at the
108    /// specified position. If it doesn't match, the slot is skipped (segment
109    /// is for a different qualifier variant).
110    ///
111    /// Build from the PID schema JSON, or construct manually:
112    /// `{ "00023" => (0, 0, "92".to_string()), "00024" => (0, 0, "93".to_string()) }`.
113    ///
114    /// Default: empty (positional assembly, no qualifier checking).
115    pub qualifier_map: std::collections::HashMap<String, (usize, usize, String)>,
116
117    /// Reject a slot when the input segment has a value outside the slot's
118    /// allowed codes at any code-bearing position (not just the primary
119    /// qualifier). Disambiguates PID-filtered slots that share a primary
120    /// qualifier but differ on a secondary code (e.g. PID 55035 SG8/PIA
121    /// variants all use 4347='5' but differ at C212/7143).
122    ///
123    /// Default: `false`. Only safe to enable on a PID-filtered MIG whose
124    /// per-slot codes reflect the AHB-narrowed allowed sets — on the raw
125    /// MIG, each slot's codes cover only one variant and strict matching
126    /// would leave most segments unconsumed.
127    pub strict_code_matching: bool,
128}
129
130/// MIG-guided assembler.
131///
132/// Takes a MIG schema and uses it as a grammar to guide consumption
133/// of parsed EDIFACT segments. Produces a generic `AssembledTree`.
134pub struct Assembler<'a> {
135    mig: &'a MigSchema,
136    config: AssemblerConfig,
137}
138
139impl<'a> Assembler<'a> {
140    pub fn new(mig: &'a MigSchema) -> Self {
141        Self {
142            mig,
143            config: AssemblerConfig::default(),
144        }
145    }
146
147    pub fn with_config(mig: &'a MigSchema, config: AssemblerConfig) -> Self {
148        Self { mig, config }
149    }
150
151    /// Assemble segments into a generic tree following MIG structure.
152    pub fn assemble_generic(
153        &self,
154        segments: &[OwnedSegment],
155    ) -> Result<AssembledTree, AssemblyError> {
156        let mut cursor = SegmentCursor::new(segments.len());
157        let mut tree = AssembledTree {
158            segments: Vec::new(),
159            groups: Vec::new(),
160            post_group_start: 0,
161            inter_group_segments: std::collections::BTreeMap::new(),
162        };
163
164        // Track which MIG segment indices were matched in the first pass
165        let mut matched_seg_indices = Vec::new();
166
167        // Process top-level segments (first pass — before groups)
168        for (i, mig_seg) in self.mig.segments.iter().enumerate() {
169            if cursor.is_exhausted() {
170                break;
171            }
172            if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
173                tree.segments.push(assembled);
174                matched_seg_indices.push(i);
175            }
176        }
177
178        // Process segment groups, interleaving root segment consumption.
179        // Some message types (e.g., MSCONS) have root segments like UNS
180        // between groups (SG2 and SG5). Before trying each group, consume
181        // any unmatched root segments at the current cursor position.
182        //
183        // When consecutive same-ID groups have variant_code set (e.g., 3 SG8
184        // entries for ZD7, Z98, ZF3), the assembler tries ALL variants at each
185        // cursor position to handle interleaved reps.
186        let mut group_idx = 0;
187        while group_idx < self.mig.segment_groups.len() {
188            if cursor.is_exhausted() {
189                break;
190            }
191
192            let mig_group = &self.mig.segment_groups[group_idx];
193
194            // Try consuming unmatched root segments before this group
195            let tree_group_idx = tree.groups.len();
196            for (i, mig_seg) in self.mig.segments.iter().enumerate() {
197                if cursor.is_exhausted() {
198                    break;
199                }
200                if matched_seg_indices.contains(&i) {
201                    continue;
202                }
203                if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
204                    tree.inter_group_segments
205                        .entry(tree_group_idx)
206                        .or_default()
207                        .push(assembled);
208                    matched_seg_indices.push(i);
209                }
210            }
211
212            // Check if this starts a variant set (consecutive same-ID groups with variant_code)
213            if mig_group.variant_code.is_some() {
214                let variant_count = self.mig.segment_groups[group_idx..]
215                    .iter()
216                    .take_while(|g| g.id == mig_group.id && g.variant_code.is_some())
217                    .count();
218                let variant_end = group_idx + variant_count;
219
220                let variant_groups = &self.mig.segment_groups[group_idx..variant_end];
221                if let Some(combined) =
222                    self.try_consume_variant_groups(segments, &mut cursor, variant_groups)?
223                {
224                    tree.groups.push(combined);
225                }
226                group_idx = variant_end;
227            } else {
228                if let Some(assembled) = self.try_consume_group(segments, &mut cursor, mig_group)? {
229                    tree.groups.push(assembled);
230                }
231                group_idx += 1;
232            }
233        }
234
235        // Mark where post-group segments start
236        tree.post_group_start = tree.segments.len();
237
238        // Second pass: try unmatched top-level segments (e.g., UNT, UNZ after groups)
239        for (i, mig_seg) in self.mig.segments.iter().enumerate() {
240            if cursor.is_exhausted() {
241                break;
242            }
243            if matched_seg_indices.contains(&i) {
244                continue;
245            }
246            if let Some(assembled) = self.try_consume_segment(segments, &mut cursor, mig_seg)? {
247                tree.segments.push(assembled);
248            }
249        }
250
251        Ok(tree)
252    }
253
254    fn try_consume_segment(
255        &self,
256        segments: &[OwnedSegment],
257        cursor: &mut SegmentCursor,
258        mig_seg: &MigSegment,
259    ) -> Result<Option<AssembledSegment>, AssemblyError> {
260        if cursor.is_exhausted() {
261            return Ok(None);
262        }
263        let seg = &segments[cursor.position()];
264        if matcher::matches_segment_tag(&seg.id, &mig_seg.id) {
265            // Qualifier check: if the MIG slot has a qualifier_map entry,
266            // verify the input segment's qualifier matches before consuming.
267            if let Some(ref num) = mig_seg.number {
268                if let Some((el_idx, comp_idx, expected)) = self.config.qualifier_map.get(num) {
269                    let actual = seg
270                        .elements
271                        .get(*el_idx)
272                        .and_then(|e| e.get(*comp_idx))
273                        .map(|s| s.as_str())
274                        .unwrap_or("");
275                    if actual != expected {
276                        return Ok(None); // Wrong qualifier — skip this slot
277                    }
278                }
279            }
280            // Note: full-code-profile matching (for disambiguating merged
281            // sibling slots sharing a primary qualifier, e.g. PID 55035 PIA
282            // variants all use 4347='5' but differ at C212/7143) is handled
283            // by the caller in `try_consume_group`'s entry-run when strict
284            // mode is on and `run_len > 1`. Doing it here would reject
285            // solo-slot segments whose codes fall outside the AHB-narrowed
286            // allowed set — those belong to the validator as COD002.
287            let mut assembled = owned_to_assembled(seg);
288            assembled.mig_number = mig_seg.number.clone();
289            cursor.advance();
290            Ok(Some(assembled))
291        } else {
292            Ok(None) // Segment not present (optional)
293        }
294    }
295
296    /// Consume the entry run of a group with best-match slot selection.
297    ///
298    /// Used when `strict_code_matching` is on and `run_len > 1` — the group
299    /// has multiple sibling entry slots with the same tag (merged PID-specific
300    /// variants). For each pending segment, picks the unused slot whose full
301    /// code profile matches best. Ties broken by MIG order. When no slot's
302    /// profile matches, falls back to the first unused tag+qualifier-matching
303    /// slot so the segment is still consumed (the validator emits COD002 if
304    /// the code is truly invalid).
305    fn consume_entry_run_best_match(
306        &self,
307        segments: &[OwnedSegment],
308        cursor: &mut SegmentCursor,
309        entry_slots: &[MigSegment],
310        instance: &mut AssembledGroupInstance,
311    ) -> Result<(), AssemblyError> {
312        let mut used = vec![false; entry_slots.len()];
313        for _ in 0..entry_slots.len() {
314            if cursor.is_exhausted() {
315                break;
316            }
317            let seg = &segments[cursor.position()];
318            let mut strict_match: Option<usize> = None;
319            let mut tag_match: Option<usize> = None;
320            for (i, slot) in entry_slots.iter().enumerate() {
321                if used[i] {
322                    continue;
323                }
324                if !matcher::matches_segment_tag(&seg.id, &slot.id) {
325                    continue;
326                }
327                if !self.segment_passes_qualifier_map(seg, slot) {
328                    continue;
329                }
330                if tag_match.is_none() {
331                    tag_match = Some(i);
332                }
333                if strict_match.is_none() && segment_matches_mig_codes(seg, slot) {
334                    strict_match = Some(i);
335                }
336            }
337            let Some(i) = strict_match.or(tag_match) else {
338                break;
339            };
340            used[i] = true;
341            let slot = &entry_slots[i];
342            let mut assembled = owned_to_assembled(seg);
343            assembled.mig_number = slot.number.clone();
344            instance.segments.push(assembled);
345            cursor.advance();
346        }
347        Ok(())
348    }
349
350    fn segment_passes_qualifier_map(&self, seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
351        let Some(ref num) = mig_seg.number else {
352            return true;
353        };
354        let Some((el_idx, comp_idx, expected)) = self.config.qualifier_map.get(num) else {
355            return true;
356        };
357        let actual = seg
358            .elements
359            .get(*el_idx)
360            .and_then(|e| e.get(*comp_idx))
361            .map(|s| s.as_str())
362            .unwrap_or("");
363        actual == expected
364    }
365
366    fn try_consume_group(
367        &self,
368        segments: &[OwnedSegment],
369        cursor: &mut SegmentCursor,
370        mig_group: &MigSegmentGroup,
371    ) -> Result<Option<AssembledGroup>, AssemblyError> {
372        let mut repetitions = Vec::new();
373        let entry_segment = mig_group.segments.first().ok_or_else(|| {
374            AssemblyError::ParseError(format!("Group {} has no segments", mig_group.id))
375        })?;
376
377        // Loop for repeating groups
378        while !cursor.is_exhausted() {
379            let iter_start = cursor.position();
380            let seg = &segments[cursor.position()];
381            if !matcher::matches_segment_tag(&seg.id, &entry_segment.id) {
382                break; // Current segment doesn't match group entry — stop repeating
383            }
384
385            // Check variant qualifier if set — tag matches but wrong variant
386            if !mig_group.variant_codes.is_empty() {
387                let (ei, ci) = mig_group.variant_qualifier_position.unwrap_or((0, 0));
388                let actual_qual = seg
389                    .elements
390                    .get(ei)
391                    .and_then(|e| e.get(ci))
392                    .map(|s| s.as_str())
393                    .unwrap_or("");
394                if !mig_group
395                    .variant_codes
396                    .iter()
397                    .any(|c| actual_qual.eq_ignore_ascii_case(c))
398                {
399                    break;
400                }
401            } else if let Some(ref expected_code) = mig_group.variant_code {
402                let (ei, ci) = mig_group.variant_qualifier_position.unwrap_or((0, 0));
403                let actual_qual = seg
404                    .elements
405                    .get(ei)
406                    .and_then(|e| e.get(ci))
407                    .map(|s| s.as_str())
408                    .unwrap_or("");
409                if !actual_qual.eq_ignore_ascii_case(expected_code) {
410                    break;
411                }
412            }
413
414            let mut instance = AssembledGroupInstance {
415                segments: Vec::new(),
416                child_groups: Vec::new(),
417                entry_mig_number: entry_segment.number.clone(),
418                variant_mig_numbers: collect_mig_numbers(mig_group),
419                skipped_segments: Vec::new(),
420            };
421
422            // Consume segments within this group instance.
423            // Process MIG slots in tag runs: for consecutive slots with the
424            // same tag, consume ALL matching input segments — not just the
425            // defined count. This handles real-world fixtures with more
426            // repetitions than the merged MIG predicts (e.g., 6 RFFs when
427            // the schema defines max 4).
428            //
429            // The entry segment (first tag run) is consumed bounded — one per
430            // defined slot — because the outer while loop uses the entry tag
431            // to delineate group repetitions.
432            let mut slot_idx = 0;
433            let mut is_entry_run = true;
434            while slot_idx < mig_group.segments.len() {
435                if cursor.is_exhausted() {
436                    break;
437                }
438                let current_tag = &mig_group.segments[slot_idx].id;
439                let run_len = mig_group.segments[slot_idx..]
440                    .iter()
441                    .take_while(|s| s.id == *current_tag)
442                    .count();
443
444                if is_entry_run {
445                    // Entry tag: consume at most run_len (preserves group boundaries)
446                    let entry_slots = &mig_group.segments[slot_idx..slot_idx + run_len];
447                    if self.config.strict_code_matching && run_len > 1 {
448                        // Best-match: among tag-matching sibling slots, prefer
449                        // the one whose full code profile matches the segment
450                        // (disambiguates PID 55035 PIA 00108/Z12 vs 00197/SRW).
451                        // Falls back to MIG order when no profile matches —
452                        // preserves assembly for codes outside any AHB-narrowed set.
453                        self.consume_entry_run_best_match(
454                            segments,
455                            cursor,
456                            entry_slots,
457                            &mut instance,
458                        )?;
459                    } else {
460                        for slot in entry_slots {
461                            if cursor.is_exhausted() {
462                                break;
463                            }
464                            if let Some(assembled) =
465                                self.try_consume_segment(segments, cursor, slot)?
466                            {
467                                instance.segments.push(assembled);
468                            }
469                        }
470                    }
471                    is_entry_run = false;
472                } else if matcher::matches_segment_tag(current_tag, &entry_segment.id) {
473                    // Non-entry slot with SAME tag as entry (e.g., CCI appears as
474                    // both entry and non-entry in merged SG30).
475                    //
476                    // Only consume if we haven't yet consumed any NON-entry-tag
477                    // segments (i.e., we're still in a consecutive entry-tag run).
478                    // Once we've consumed a different tag (like CAV), seeing the
479                    // entry tag again means a new rep boundary.
480                    //
481                    // z35: entry CCI → CAV CAV → sees CCI → has_other=true → break ✓
482                    // z39: entry CCI → (no CAV) → sees CCI → has_other=false → consume ✓
483                    //      then CCI CCI → CAV → sees CCI → has_other=true → break
484                    //      BUT: z39 needs CCI-CAV-CCI-CAV structure
485                    //
486                    // Better heuristic: check if ALL remaining slots from here are
487                    // entry-tag + non-entry pairs. If the current slot is entry-tag
488                    // and the NEXT input segment after it would be a non-entry tag,
489                    // consume — it's a continuation. Otherwise break.
490                    if cursor.is_exhausted() {
491                        break;
492                    }
493                    let seg = &segments[cursor.position()];
494                    if !matcher::matches_segment_tag(&seg.id, current_tag) {
495                        break;
496                    }
497                    // Check: is there a non-entry segment AFTER this entry-tag?
498                    // If so, this CCI+CAV pair is part of the current rep.
499                    let has_following_non_entry = if cursor.position() + 1 < segments.len() {
500                        let next = &segments[cursor.position() + 1];
501                        !matcher::matches_segment_tag(&next.id, &entry_segment.id)
502                            && mig_group.segments.iter().any(|s| {
503                                matcher::matches_segment_tag(&next.id, &s.id)
504                                    && !matcher::matches_segment_tag(&s.id, &entry_segment.id)
505                            })
506                    } else {
507                        false
508                    };
509                    if has_following_non_entry {
510                        // CCI followed by CAV → consume as continuation pair
511                        instance.segments.push(owned_to_assembled(seg));
512                        cursor.advance();
513                    } else {
514                        // CCI followed by CCI or unknown → let outer loop decide
515                        break;
516                    }
517                } else {
518                    // Non-entry tag: consume bounded slots first (with mig_number),
519                    // then greedily consume extras (without mig_number).
520                    // The bounded slots get mig_number from the MIG definition so
521                    // the validator can distinguish same-tag segments (e.g., DTM+92
522                    // vs DTM+93 both in SG4).
523                    let slots = &mig_group.segments[slot_idx..slot_idx + run_len];
524                    if self.config.strict_code_matching && run_len > 1 {
525                        self.consume_entry_run_best_match(segments, cursor, slots, &mut instance)?;
526                    } else {
527                        for slot in slots {
528                            if cursor.is_exhausted() {
529                                break;
530                            }
531                            if let Some(assembled) =
532                                self.try_consume_segment(segments, cursor, slot)?
533                            {
534                                instance.segments.push(assembled);
535                            }
536                        }
537                    }
538                    // Greedily consume any remaining same-tag segments beyond the MIG count
539                    while !cursor.is_exhausted() {
540                        let seg = &segments[cursor.position()];
541                        if matcher::matches_segment_tag(&seg.id, current_tag) {
542                            instance.segments.push(owned_to_assembled(seg));
543                            cursor.advance();
544                        } else {
545                            break;
546                        }
547                    }
548                }
549
550                slot_idx += run_len;
551
552                // Point A: Skip unknown segments between MIG slot runs.
553                // When skip mode is ON and we just finished a slot run but the
554                // current segment doesn't match any remaining MIG slot, nested
555                // group entry, or the entry tag, skip it.
556                if self.config.skip_unknown_segments {
557                    while !cursor.is_exhausted() {
558                        let seg = &segments[cursor.position()];
559                        // Stop if it matches the entry tag (next group repetition)
560                        if matcher::matches_segment_tag(&seg.id, &entry_segment.id) {
561                            break;
562                        }
563                        // Stop if it matches any remaining MIG slot
564                        if mig_group.segments[slot_idx..]
565                            .iter()
566                            .any(|s| matcher::matches_segment_tag(&seg.id, &s.id))
567                        {
568                            break;
569                        }
570                        // Stop if it matches any nested group entry
571                        if mig_group.nested_groups.iter().any(|ng| {
572                            ng.segments
573                                .first()
574                                .is_some_and(|es| matcher::matches_segment_tag(&seg.id, &es.id))
575                        }) {
576                            break;
577                        }
578                        // Unknown segment — skip it
579                        instance.skipped_segments.push(owned_to_assembled(seg));
580                        cursor.advance();
581                    }
582                }
583            }
584
585            // Consume nested groups (variant-aware for same-ID groups)
586            let mut nested_idx = 0;
587            while nested_idx < mig_group.nested_groups.len() {
588                if cursor.is_exhausted() {
589                    break;
590                }
591                let nested = &mig_group.nested_groups[nested_idx];
592
593                if nested.variant_code.is_some() {
594                    // Variant set: collect consecutive same-ID groups with variant_code
595                    let variant_count = mig_group.nested_groups[nested_idx..]
596                        .iter()
597                        .take_while(|g| g.id == nested.id && g.variant_code.is_some())
598                        .count();
599                    let variant_end = nested_idx + variant_count;
600                    let variant_groups = &mig_group.nested_groups[nested_idx..variant_end];
601                    if let Some(combined) =
602                        self.try_consume_variant_groups(segments, cursor, variant_groups)?
603                    {
604                        instance.child_groups.push(combined);
605                    }
606                    nested_idx = variant_end;
607                } else {
608                    if let Some(assembled) = self.try_consume_group(segments, cursor, nested)? {
609                        instance.child_groups.push(assembled);
610                    }
611                    nested_idx += 1;
612                }
613            }
614
615            // Guard against infinite loops: if no progress was made this iteration
616            // (entry tag matched but the entry segment was rejected by e.g. a
617            // qualifier_map mismatch), stop. Pushing an empty rep per iteration
618            // would allocate unbounded memory (see collect_mig_numbers call in
619            // the instance constructor).
620            if cursor.position() == iter_start {
621                break;
622            }
623            repetitions.push(instance);
624        }
625
626        if repetitions.is_empty() {
627            Ok(None)
628        } else {
629            Ok(Some(AssembledGroup {
630                group_id: mig_group.id.clone(),
631                repetitions,
632            }))
633        }
634    }
635
636    /// Consume interleaved repetitions of variant groups.
637    ///
638    /// At each cursor position, tries all variant definitions to find which one
639    /// matches the entry segment's qualifier. Collects all reps into one
640    /// `AssembledGroup` with the shared group_id.
641    fn try_consume_variant_groups(
642        &self,
643        segments: &[OwnedSegment],
644        cursor: &mut SegmentCursor,
645        variants: &[MigSegmentGroup],
646    ) -> Result<Option<AssembledGroup>, AssemblyError> {
647        let group_id = variants[0].id.clone();
648        let entry_tag = variants[0]
649            .segments
650            .first()
651            .map(|s| s.id.as_str())
652            .unwrap_or("");
653        let mut all_reps = Vec::new();
654
655        while !cursor.is_exhausted() {
656            let seg = &segments[cursor.position()];
657            if !matcher::matches_segment_tag(&seg.id, entry_tag) {
658                break;
659            }
660
661            // Find which variant matches this segment's qualifier.
662            // Each variant may have its qualifier at a different element position
663            // (e.g., CCI+Z19 has qualifier at [0][0], but CCI+++Z15 at [2][0]).
664            let matched = variants.iter().find(|v| {
665                let (ei, ci) = v.variant_qualifier_position.unwrap_or((0, 0));
666                let actual_qual = seg
667                    .elements
668                    .get(ei)
669                    .and_then(|e| e.get(ci))
670                    .map(|s| s.as_str())
671                    .unwrap_or("");
672                if !v.variant_codes.is_empty() {
673                    v.variant_codes
674                        .iter()
675                        .any(|c| actual_qual.eq_ignore_ascii_case(c))
676                } else if let Some(ref expected_code) = v.variant_code {
677                    actual_qual.eq_ignore_ascii_case(expected_code)
678                } else {
679                    false
680                }
681            });
682
683            if let Some(variant) = matched {
684                if let Some(group) = self.try_consume_group(segments, cursor, variant)? {
685                    all_reps.extend(group.repetitions);
686                } else {
687                    break;
688                }
689            } else {
690                // No variant matches — try consuming with the first variant as
691                // fallback to avoid getting stuck. This handles edge cases where
692                // the qualifier doesn't exactly match any variant code.
693                if let Some(group) = self.try_consume_group(segments, cursor, &variants[0])? {
694                    all_reps.extend(group.repetitions);
695                } else {
696                    break;
697                }
698            }
699        }
700
701        if all_reps.is_empty() {
702            Ok(None)
703        } else {
704            Ok(Some(AssembledGroup {
705                group_id,
706                repetitions: all_reps,
707            }))
708        }
709    }
710
711    /// Assemble segments with diagnostic collection.
712    ///
713    /// Returns the assembled tree plus diagnostics for segments not consumed
714    /// by the MIG-guided assembly. Existing `assemble_generic()` is unchanged.
715    pub fn assemble_with_diagnostics(
716        &self,
717        segments: &[OwnedSegment],
718    ) -> (AssembledTree, Vec<StructureDiagnostic>) {
719        let mut diagnostics = Vec::new();
720
721        let tree = match self.assemble_generic(segments) {
722            Ok(tree) => tree,
723            Err(e) => {
724                diagnostics.push(StructureDiagnostic {
725                    kind: StructureDiagnosticKind::UnexpectedSegment,
726                    segment_id: String::new(),
727                    position: 0,
728                    message: format!("Assembly failed: {e}"),
729                });
730                return (
731                    AssembledTree {
732                        segments: Vec::new(),
733                        groups: Vec::new(),
734                        post_group_start: 0,
735                        inter_group_segments: std::collections::BTreeMap::new(),
736                    },
737                    diagnostics,
738                );
739            }
740        };
741
742        // Count consumed segments in the assembled tree
743        let consumed = count_tree_segments(&tree);
744
745        // Segments beyond consumed count are unconsumed
746        for (i, seg) in segments.iter().enumerate().skip(consumed) {
747            diagnostics.push(StructureDiagnostic {
748                kind: StructureDiagnosticKind::UnexpectedSegment,
749                segment_id: seg.id.clone(),
750                position: i,
751                message: format!(
752                    "Segment '{}' at position {} was not consumed by MIG-guided assembly",
753                    seg.id, i
754                ),
755            });
756        }
757
758        (tree, diagnostics)
759    }
760}
761
762fn count_tree_segments(tree: &AssembledTree) -> usize {
763    let mut count = tree.segments.len();
764    for group in &tree.groups {
765        count += count_group_segments(group);
766    }
767    // Count inter-group segments (e.g., UNS+D between groups)
768    for segs in tree.inter_group_segments.values() {
769        count += segs.len();
770    }
771    count
772}
773
774fn count_group_segments(group: &AssembledGroup) -> usize {
775    let mut count = 0;
776    for rep in &group.repetitions {
777        count += rep.segments.len();
778        count += rep.skipped_segments.len();
779        for child in &rep.child_groups {
780            count += count_group_segments(child);
781        }
782    }
783    count
784}
785
786/// Collect all MIG `Number`s from a segment group definition, recursively.
787///
788/// This includes numbers from direct segments and from nested groups.
789/// Used to populate `AssembledGroupInstance::variant_mig_numbers`.
790fn collect_mig_numbers(group: &MigSegmentGroup) -> Vec<String> {
791    let mut numbers = Vec::new();
792    for seg in &group.segments {
793        if let Some(ref num) = seg.number {
794            numbers.push(num.clone());
795        }
796    }
797    for nested in &group.nested_groups {
798        numbers.extend(collect_mig_numbers(nested));
799    }
800    numbers
801}
802
803pub fn owned_to_assembled(seg: &OwnedSegment) -> AssembledSegment {
804    AssembledSegment {
805        tag: seg.id.clone(),
806        elements: seg.elements.clone(),
807        mig_number: None,
808    }
809}
810
811/// Check every code-bearing position declared on a MIG segment against the
812/// corresponding value on the input segment.
813///
814/// Used by `try_consume_segment` to disambiguate slots that share the same
815/// primary qualifier but differ on a secondary code. Returns `true` when the
816/// input segment's values at each declared position are either empty
817/// (optional) or in the slot's allowed set.
818fn segment_matches_mig_codes(seg: &OwnedSegment, mig_seg: &MigSegment) -> bool {
819    let actual_at = |el: usize, c: usize| -> &str {
820        seg.elements
821            .get(el)
822            .and_then(|e| e.get(c))
823            .map(|s| s.as_str())
824            .unwrap_or("")
825    };
826    for de in &mig_seg.data_elements {
827        if !de.codes.is_empty() {
828            let actual = actual_at(de.position, 0);
829            if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
830                return false;
831            }
832        }
833    }
834    for comp in &mig_seg.composites {
835        for de in &comp.data_elements {
836            if !de.codes.is_empty() {
837                let actual = actual_at(comp.position, de.position);
838                if !actual.is_empty() && !de.codes.iter().any(|c| c.value == actual) {
839                    return false;
840                }
841            }
842        }
843    }
844    true
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::test_support::{make_mig_group, make_mig_group_with_variant, make_mig_segment};
851
852    fn make_owned_seg(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
853        OwnedSegment {
854            id: id.to_string(),
855            elements: elements
856                .into_iter()
857                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
858                .collect(),
859            segment_number: 0,
860        }
861    }
862
863    fn make_mig_schema(segments: Vec<&str>, groups: Vec<MigSegmentGroup>) -> MigSchema {
864        MigSchema {
865            message_type: "UTILMD".to_string(),
866            variant: Some("Strom".to_string()),
867            version: "S2.1".to_string(),
868            publication_date: "2025-03-20".to_string(),
869            author: "BDEW".to_string(),
870            format_version: "FV2504".to_string(),
871            source_file: "test".to_string(),
872            segments: segments.into_iter().map(make_mig_segment).collect(),
873            segment_groups: groups,
874        }
875    }
876
877    #[test]
878    fn test_assembler_top_level_segments_only() {
879        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
880
881        let segments = vec![
882            make_owned_seg("UNH", vec![vec!["001", "UTILMD:D:11A:UN:S2.1"]]),
883            make_owned_seg("BGM", vec![vec!["E01", "DOC001"]]),
884            make_owned_seg("DTM", vec![vec!["137", "20250101", "102"]]),
885            make_owned_seg("UNT", vec![vec!["4", "001"]]),
886        ];
887
888        let assembler = Assembler::new(&mig);
889        let result = assembler.assemble_generic(&segments).unwrap();
890
891        assert_eq!(result.segments.len(), 4);
892        assert_eq!(result.segments[0].tag, "UNH");
893        assert_eq!(result.segments[1].tag, "BGM");
894        assert_eq!(result.segments[2].tag, "DTM");
895        assert_eq!(result.segments[3].tag, "UNT");
896        assert!(result.groups.is_empty());
897    }
898
899    #[test]
900    fn test_assembler_with_segment_group() {
901        let mig = make_mig_schema(
902            vec!["UNH", "BGM"],
903            vec![
904                make_mig_group("SG2", vec!["NAD"], vec![]),
905                make_mig_group("SG4", vec!["IDE", "STS"], vec![]),
906            ],
907        );
908
909        let segments = vec![
910            make_owned_seg("UNH", vec![vec!["001"]]),
911            make_owned_seg("BGM", vec![vec!["E01"]]),
912            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
913            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
914            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
915            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
916        ];
917
918        let assembler = Assembler::new(&mig);
919        let result = assembler.assemble_generic(&segments).unwrap();
920
921        // Top-level: UNH, BGM
922        assert_eq!(result.segments.len(), 2);
923        // SG2: 2 repetitions (two NAD segments)
924        assert_eq!(result.groups.len(), 2);
925        assert_eq!(result.groups[0].group_id, "SG2");
926        assert_eq!(result.groups[0].repetitions.len(), 2);
927        assert_eq!(result.groups[0].repetitions[0].segments[0].tag, "NAD");
928        assert_eq!(result.groups[0].repetitions[1].segments[0].tag, "NAD");
929        // SG4: 1 repetition (IDE + STS)
930        assert_eq!(result.groups[1].group_id, "SG4");
931        assert_eq!(result.groups[1].repetitions.len(), 1);
932        assert_eq!(result.groups[1].repetitions[0].segments.len(), 2);
933    }
934
935    #[test]
936    fn test_assembler_nested_groups() {
937        let sg3 = make_mig_group("SG3", vec!["CTA", "COM"], vec![]);
938        let mig = make_mig_schema(
939            vec!["UNH", "BGM"],
940            vec![make_mig_group("SG2", vec!["NAD"], vec![sg3])],
941        );
942
943        let segments = vec![
944            make_owned_seg("UNH", vec![vec!["001"]]),
945            make_owned_seg("BGM", vec![vec!["E01"]]),
946            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
947            make_owned_seg("CTA", vec![vec!["IC", "Kontakt"]]),
948            make_owned_seg("COM", vec![vec!["040@example.com", "EM"]]),
949        ];
950
951        let assembler = Assembler::new(&mig);
952        let result = assembler.assemble_generic(&segments).unwrap();
953
954        // SG2 has 1 repetition
955        let sg2 = &result.groups[0];
956        assert_eq!(sg2.group_id, "SG2");
957        assert_eq!(sg2.repetitions.len(), 1);
958
959        let sg2_inst = &sg2.repetitions[0];
960        assert_eq!(sg2_inst.segments[0].tag, "NAD");
961
962        // SG3 nested inside SG2
963        assert_eq!(sg2_inst.child_groups.len(), 1);
964        let sg3 = &sg2_inst.child_groups[0];
965        assert_eq!(sg3.group_id, "SG3");
966        assert_eq!(sg3.repetitions[0].segments.len(), 2);
967        assert_eq!(sg3.repetitions[0].segments[0].tag, "CTA");
968        assert_eq!(sg3.repetitions[0].segments[1].tag, "COM");
969    }
970
971    #[test]
972    fn test_assembler_optional_segments_skipped() {
973        // MIG expects UNH, BGM, DTM, UNT but input has no DTM
974        let mig = make_mig_schema(vec!["UNH", "BGM", "DTM", "UNT"], vec![]);
975
976        let segments = vec![
977            make_owned_seg("UNH", vec![vec!["001"]]),
978            make_owned_seg("BGM", vec![vec!["E01"]]),
979            make_owned_seg("UNT", vec![vec!["2", "001"]]),
980        ];
981
982        let assembler = Assembler::new(&mig);
983        let result = assembler.assemble_generic(&segments).unwrap();
984
985        // DTM is skipped (optional), UNT consumed
986        assert_eq!(result.segments.len(), 3);
987        assert_eq!(result.segments[0].tag, "UNH");
988        assert_eq!(result.segments[1].tag, "BGM");
989        assert_eq!(result.segments[2].tag, "UNT");
990    }
991
992    #[test]
993    fn test_assembler_empty_segments() {
994        let mig = make_mig_schema(vec!["UNH"], vec![]);
995        let assembler = Assembler::new(&mig);
996        let result = assembler.assemble_generic(&[]).unwrap();
997        assert!(result.segments.is_empty());
998        assert!(result.groups.is_empty());
999    }
1000
1001    #[test]
1002    fn test_assembler_preserves_element_data() {
1003        let mig = make_mig_schema(vec!["DTM"], vec![]);
1004
1005        let segments = vec![make_owned_seg(
1006            "DTM",
1007            vec![vec!["137", "202501010000+01", "303"]],
1008        )];
1009
1010        let assembler = Assembler::new(&mig);
1011        let result = assembler.assemble_generic(&segments).unwrap();
1012
1013        let dtm = &result.segments[0];
1014        assert_eq!(dtm.elements[0][0], "137");
1015        assert_eq!(dtm.elements[0][1], "202501010000+01");
1016        assert_eq!(dtm.elements[0][2], "303");
1017    }
1018
1019    #[test]
1020    fn test_group_instance_as_assembled_tree() {
1021        // Build an SG4 instance with root segments (IDE, STS) and child groups (SG5)
1022        let sg5 = AssembledGroup {
1023            group_id: "SG5".to_string(),
1024            repetitions: vec![AssembledGroupInstance {
1025                segments: vec![AssembledSegment {
1026                    tag: "LOC".to_string(),
1027                    elements: vec![vec!["Z16".to_string(), "DE000111222333".to_string()]],
1028                    mig_number: None,
1029                }],
1030                child_groups: vec![],
1031                entry_mig_number: None,
1032                variant_mig_numbers: vec![],
1033                skipped_segments: vec![],
1034            }],
1035        };
1036
1037        let sg4_instance = AssembledGroupInstance {
1038            segments: vec![
1039                AssembledSegment {
1040                    tag: "IDE".to_string(),
1041                    elements: vec![vec!["24".to_string(), "TX001".to_string()]],
1042                    mig_number: None,
1043                },
1044                AssembledSegment {
1045                    tag: "STS".to_string(),
1046                    elements: vec![vec!["7".to_string()]],
1047                    mig_number: None,
1048                },
1049            ],
1050            child_groups: vec![sg5],
1051            entry_mig_number: None,
1052            variant_mig_numbers: vec![],
1053            skipped_segments: vec![],
1054        };
1055
1056        let sub_tree = sg4_instance.as_assembled_tree();
1057
1058        // Root segments of sub-tree are the SG4 instance's segments
1059        assert_eq!(sub_tree.segments.len(), 2);
1060        assert_eq!(sub_tree.segments[0].tag, "IDE");
1061        assert_eq!(sub_tree.segments[1].tag, "STS");
1062
1063        // Groups of sub-tree are the SG4 instance's child groups
1064        assert_eq!(sub_tree.groups.len(), 1);
1065        assert_eq!(sub_tree.groups[0].group_id, "SG5");
1066
1067        // post_group_start marks where root segments end
1068        assert_eq!(sub_tree.post_group_start, 2);
1069    }
1070
1071    #[test]
1072    fn test_assembler_from_parsed_edifact() {
1073        // End-to-end: parse raw EDIFACT, then assemble
1074        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'";
1075        let segments = crate::tokenize::parse_to_segments(input).unwrap();
1076
1077        let mig = make_mig_schema(vec!["UNB", "UNH", "BGM", "DTM", "UNT", "UNZ"], vec![]);
1078
1079        let assembler = Assembler::new(&mig);
1080        let result = assembler.assemble_generic(&segments).unwrap();
1081
1082        assert!(result.segments.iter().any(|s| s.tag == "UNH"));
1083        assert!(result.segments.iter().any(|s| s.tag == "BGM"));
1084        assert!(result.segments.iter().any(|s| s.tag == "DTM"));
1085    }
1086
1087    #[test]
1088    fn test_assemble_with_diagnostics_clean_input() {
1089        let mig = make_mig_schema(vec!["UNH", "BGM", "UNT"], vec![]);
1090        let segments = vec![
1091            make_owned_seg("UNH", vec![vec!["001"]]),
1092            make_owned_seg("BGM", vec![vec!["E01"]]),
1093            make_owned_seg("UNT", vec![vec!["2", "001"]]),
1094        ];
1095        let assembler = Assembler::new(&mig);
1096        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1097        assert_eq!(tree.segments.len(), 3);
1098        assert!(
1099            diagnostics.is_empty(),
1100            "Clean input should have no diagnostics"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_assemble_with_diagnostics_unconsumed_segments() {
1106        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![]);
1107        let segments = vec![
1108            make_owned_seg("UNH", vec![vec!["001"]]),
1109            make_owned_seg("BGM", vec![vec!["E01"]]),
1110            make_owned_seg("FTX", vec![vec!["AAA", "extra text"]]),
1111        ];
1112        let assembler = Assembler::new(&mig);
1113        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1114        assert_eq!(tree.segments.len(), 2);
1115        assert_eq!(diagnostics.len(), 1);
1116        assert_eq!(
1117            diagnostics[0].kind,
1118            StructureDiagnosticKind::UnexpectedSegment
1119        );
1120        assert_eq!(diagnostics[0].segment_id, "FTX");
1121        assert_eq!(diagnostics[0].position, 2);
1122    }
1123
1124    #[test]
1125    fn test_assemble_with_diagnostics_multiple_unconsumed() {
1126        let mig = make_mig_schema(vec!["UNH"], vec![]);
1127        let segments = vec![
1128            make_owned_seg("UNH", vec![vec!["001"]]),
1129            make_owned_seg("FOO", vec![]),
1130            make_owned_seg("BAR", vec![]),
1131            make_owned_seg("BAZ", vec![]),
1132        ];
1133        let assembler = Assembler::new(&mig);
1134        let (tree, diagnostics) = assembler.assemble_with_diagnostics(&segments);
1135        assert_eq!(tree.segments.len(), 1);
1136        assert_eq!(diagnostics.len(), 3);
1137        assert_eq!(diagnostics[0].segment_id, "FOO");
1138        assert_eq!(diagnostics[1].segment_id, "BAR");
1139        assert_eq!(diagnostics[2].segment_id, "BAZ");
1140    }
1141
1142    // ── Non-entry segment mig_number assignment tests ──
1143
1144    #[test]
1145    fn test_non_entry_segments_get_mig_number_from_bounded_slots() {
1146        // MIG group SG4 has entry IDE + two numbered DTMs + STS.
1147        // The assembler should assign mig_number from the MIG slots to
1148        // each non-entry segment via the bounded consumption path.
1149        use crate::test_support::make_mig_segment_numbered;
1150
1151        let sg4 = MigSegmentGroup {
1152            segments: vec![
1153                make_mig_segment_numbered("IDE", "00020"),
1154                make_mig_segment_numbered("DTM", "00023"),
1155                make_mig_segment_numbered("DTM", "00024"),
1156                make_mig_segment_numbered("STS", "00035"),
1157            ],
1158            ..make_mig_group("SG4", vec![], vec![])
1159        };
1160        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1161
1162        let segments = vec![
1163            make_owned_seg("UNH", vec![vec!["001"]]),
1164            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1165            make_owned_seg("DTM", vec![vec!["92", "202505312200+00", "303"]]),
1166            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1167            make_owned_seg("STS", vec![vec!["7"], vec![], vec!["E01"]]),
1168        ];
1169
1170        let assembler = Assembler::new(&mig);
1171        let tree = assembler.assemble_generic(&segments).unwrap();
1172
1173        let sg4_instance = &tree.groups[0].repetitions[0];
1174
1175        // IDE (entry) gets mig_number from try_consume_segment
1176        assert_eq!(sg4_instance.segments[0].tag, "IDE");
1177        assert_eq!(sg4_instance.segments[0].mig_number.as_deref(), Some("00020"));
1178
1179        // DTM+92 gets mig_number "00023" from first DTM slot
1180        assert_eq!(sg4_instance.segments[1].tag, "DTM");
1181        assert_eq!(sg4_instance.segments[1].mig_number.as_deref(), Some("00023"));
1182
1183        // DTM+93 gets mig_number "00024" from second DTM slot
1184        assert_eq!(sg4_instance.segments[2].tag, "DTM");
1185        assert_eq!(sg4_instance.segments[2].mig_number.as_deref(), Some("00024"));
1186
1187        // STS gets mig_number "00035"
1188        assert_eq!(sg4_instance.segments[3].tag, "STS");
1189        assert_eq!(sg4_instance.segments[3].mig_number.as_deref(), Some("00035"));
1190
1191        // variant_mig_numbers should contain all four
1192        assert!(sg4_instance.variant_mig_numbers.contains(&"00020".to_string()));
1193        assert!(sg4_instance.variant_mig_numbers.contains(&"00023".to_string()));
1194        assert!(sg4_instance.variant_mig_numbers.contains(&"00024".to_string()));
1195        assert!(sg4_instance.variant_mig_numbers.contains(&"00035".to_string()));
1196    }
1197
1198    #[test]
1199    fn test_greedy_extra_segments_get_no_mig_number() {
1200        // MIG defines 1 DTM slot, but input has 2 DTMs.
1201        // First DTM gets mig_number from bounded path, second gets None (greedy extra).
1202        use crate::test_support::make_mig_segment_numbered;
1203
1204        let sg4 = MigSegmentGroup {
1205            segments: vec![
1206                make_mig_segment_numbered("IDE", "00020"),
1207                make_mig_segment_numbered("DTM", "00023"),
1208            ],
1209            ..make_mig_group("SG4", vec![], vec![])
1210        };
1211        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1212
1213        let segments = vec![
1214            make_owned_seg("UNH", vec![vec!["001"]]),
1215            make_owned_seg("IDE", vec![vec!["24"]]),
1216            make_owned_seg("DTM", vec![vec!["92", "20250531"]]),
1217            make_owned_seg("DTM", vec![vec!["93", "20251231"]]), // extra beyond MIG
1218        ];
1219
1220        let assembler = Assembler::new(&mig);
1221        let tree = assembler.assemble_generic(&segments).unwrap();
1222
1223        let sg4_instance = &tree.groups[0].repetitions[0];
1224        assert_eq!(sg4_instance.segments.len(), 3); // IDE + 2 DTMs
1225
1226        // First DTM: bounded slot → mig_number set
1227        assert_eq!(sg4_instance.segments[1].mig_number.as_deref(), Some("00023"));
1228
1229        // Second DTM: greedy extra → mig_number None
1230        assert_eq!(sg4_instance.segments[2].mig_number, None);
1231    }
1232
1233    // ── Qualifier-aware assembly tests ──
1234
1235    #[test]
1236    fn test_qualifier_map_prevents_wrong_slot_consumption() {
1237        // MIG defines DTM(00023) + DTM(00024). Input has only DTM+93.
1238        // Without qualifier map: DTM+93 consumed by slot 00023 (wrong).
1239        // With qualifier map: slot 00023 expects "92", skips DTM+93.
1240        //   Slot 00024 expects "93", consumes DTM+93 correctly.
1241        use crate::test_support::make_mig_segment_numbered;
1242        use std::collections::HashMap;
1243
1244        let sg4 = MigSegmentGroup {
1245            segments: vec![
1246                make_mig_segment_numbered("IDE", "00020"),
1247                make_mig_segment_numbered("DTM", "00023"),
1248                make_mig_segment_numbered("DTM", "00024"),
1249            ],
1250            ..make_mig_group("SG4", vec![], vec![])
1251        };
1252        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1253
1254        let segments = vec![
1255            make_owned_seg("UNH", vec![vec!["001"]]),
1256            make_owned_seg("IDE", vec![vec!["24"]]),
1257            make_owned_seg("DTM", vec![vec!["93", "202512312300+00", "303"]]),
1258        ];
1259
1260        let mut qualifier_map = HashMap::new();
1261        qualifier_map.insert("00023".to_string(), (0, 0, "92".to_string()));
1262        qualifier_map.insert("00024".to_string(), (0, 0, "93".to_string()));
1263
1264        let config = AssemblerConfig {
1265            skip_unknown_segments: false,
1266            qualifier_map,
1267            ..Default::default()
1268        };
1269        let assembler = Assembler::with_config(&mig, config);
1270        let tree = assembler.assemble_generic(&segments).unwrap();
1271
1272        let sg4_instance = &tree.groups[0].repetitions[0];
1273
1274        // DTM+93 should be consumed by slot 00024, NOT slot 00023
1275        assert_eq!(sg4_instance.segments.len(), 2); // IDE + DTM+93
1276        let dtm = &sg4_instance.segments[1];
1277        assert_eq!(dtm.tag, "DTM");
1278        assert_eq!(
1279            dtm.mig_number.as_deref(),
1280            Some("00024"),
1281            "DTM+93 should get mig_number 00024 (not 00023)"
1282        );
1283    }
1284
1285    #[test]
1286    fn test_group_entry_qualifier_mismatch_does_not_infinite_loop() {
1287        // Regression: when a group's entry segment has a qualifier_map entry
1288        // but the input segment's qualifier does not match, the outer
1289        // `while !cursor.is_exhausted()` loop in try_consume_group used to
1290        // spin forever — entry tag matched, so the loop kept going, but
1291        // try_consume_segment rejected the segment on qualifier mismatch, so
1292        // the cursor never advanced. Each iteration allocated a fresh
1293        // variant_mig_numbers Vec via collect_mig_numbers, driving unbounded
1294        // memory growth (observed: 3.5 GB → OOM in ~3 s on staging for
1295        // FV2604/UTILMD_Gas/PID 44004 with LOC+172).
1296        use crate::test_support::make_mig_segment_numbered;
1297        use std::collections::HashMap;
1298
1299        let sg5 = MigSegmentGroup {
1300            segments: vec![make_mig_segment_numbered("LOC", "00050")],
1301            ..make_mig_group("SG5", vec![], vec![])
1302        };
1303        let mig = make_mig_schema(vec!["UNH"], vec![sg5]);
1304
1305        let segments = vec![
1306            make_owned_seg("UNH", vec![vec!["001"]]),
1307            // LOC entry tag matches, but qualifier "172" ≠ expected "Z16"
1308            make_owned_seg("LOC", vec![vec!["172"], vec!["92003964705"]]),
1309        ];
1310
1311        let mut qualifier_map = HashMap::new();
1312        qualifier_map.insert("00050".to_string(), (0, 0, "Z16".to_string()));
1313
1314        let config = AssemblerConfig {
1315            skip_unknown_segments: false,
1316            qualifier_map,
1317            ..Default::default()
1318        };
1319        let assembler = Assembler::with_config(&mig, config);
1320
1321        // Before the fix this would loop forever. Bound the assertion with a
1322        // generous wall-clock guard so a regression is a clear test failure
1323        // rather than a hanging CI job.
1324        let start = std::time::Instant::now();
1325        let tree = assembler.assemble_generic(&segments).unwrap();
1326        assert!(
1327            start.elapsed() < std::time::Duration::from_secs(5),
1328            "assembly took {:?} — suspected infinite-loop regression",
1329            start.elapsed()
1330        );
1331
1332        // LOC+172 didn't match SG5's qualifier, so SG5 should be empty.
1333        // The LOC segment remains unconsumed (caller will surface it as a
1334        // structure diagnostic).
1335        assert!(tree.groups.is_empty());
1336    }
1337
1338    // ── Skip-unknown-segments tests ──
1339
1340    #[test]
1341    fn test_skip_unknown_segment_between_slots() {
1342        // MIG group expects [SEQ, CCI], input has [SEQ, RFF, CCI].
1343        // With skip ON, RFF is skipped and CCI is consumed.
1344        // With skip OFF (default), CCI is lost because RFF stalls the cursor.
1345        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1346        let mig = make_mig_schema(vec!["UNH"], vec![sg8.clone()]);
1347
1348        let segments = vec![
1349            make_owned_seg("UNH", vec![vec!["001"]]),
1350            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1351            make_owned_seg("RFF", vec![vec!["Z38", "CROSSREF"]]),
1352            make_owned_seg("CCI", vec![vec!["Z30"]]),
1353        ];
1354
1355        // Skip OFF: CCI not consumed (RFF stalls cursor after SEQ)
1356        let off = Assembler::new(&mig);
1357        let tree_off = off.assemble_generic(&segments).unwrap();
1358        let sg8_off = &tree_off.groups[0];
1359        assert_eq!(sg8_off.repetitions[0].segments.len(), 1); // Only SEQ
1360        assert_eq!(sg8_off.repetitions[0].segments[0].tag, "SEQ");
1361
1362        // Skip ON: RFF skipped, CCI consumed
1363        let on = Assembler::with_config(
1364            &mig,
1365            AssemblerConfig {
1366                skip_unknown_segments: true,
1367                ..Default::default()
1368            },
1369        );
1370        let tree_on = on.assemble_generic(&segments).unwrap();
1371        let sg8_on = &tree_on.groups[0];
1372        assert_eq!(sg8_on.repetitions[0].segments.len(), 2); // SEQ + CCI
1373        assert_eq!(sg8_on.repetitions[0].segments[0].tag, "SEQ");
1374        assert_eq!(sg8_on.repetitions[0].segments[1].tag, "CCI");
1375    }
1376
1377    #[test]
1378    fn test_skip_preserves_on_instance() {
1379        // Skipped segments are stored in instance.skipped_segments
1380        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1381        let mig = make_mig_schema(vec!["UNH"], vec![sg8]);
1382
1383        let segments = vec![
1384            make_owned_seg("UNH", vec![vec!["001"]]),
1385            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1386            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1387            make_owned_seg("DTM", vec![vec!["92", "20250101"]]),
1388            make_owned_seg("CCI", vec![vec!["Z30"]]),
1389        ];
1390
1391        let assembler = Assembler::with_config(
1392            &mig,
1393            AssemblerConfig {
1394                skip_unknown_segments: true,
1395                ..Default::default()
1396            },
1397        );
1398        let tree = assembler.assemble_generic(&segments).unwrap();
1399        let instance = &tree.groups[0].repetitions[0];
1400
1401        assert_eq!(instance.segments.len(), 2); // SEQ + CCI
1402        assert_eq!(instance.skipped_segments.len(), 2); // RFF + DTM
1403        assert_eq!(instance.skipped_segments[0].tag, "RFF");
1404        assert_eq!(instance.skipped_segments[1].tag, "DTM");
1405    }
1406
1407    #[test]
1408    fn test_skip_mode_off_default() {
1409        // Assembler::new() doesn't skip (backwards compat)
1410        let mig = make_mig_schema(vec![], vec![]);
1411        let assembler = Assembler::new(&mig);
1412        assert!(!assembler.config.skip_unknown_segments);
1413    }
1414
1415    #[test]
1416    fn test_skip_does_not_consume_nested_group_entry() {
1417        // Skip must NOT consume segments that are nested group entries.
1418        // SG4 expects [IDE, STS], nested SG5 expects [LOC].
1419        // Input: IDE, FOO, STS, LOC. FOO should be skipped, LOC goes to SG5.
1420        let sg5 = make_mig_group("SG5", vec!["LOC"], vec![]);
1421        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![sg5]);
1422        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1423
1424        let segments = vec![
1425            make_owned_seg("UNH", vec![vec!["001"]]),
1426            make_owned_seg("IDE", vec![vec!["24"]]),
1427            make_owned_seg("FOO", vec![vec!["unknown"]]),
1428            make_owned_seg("STS", vec![vec!["7"]]),
1429            make_owned_seg("LOC", vec![vec!["Z16"]]),
1430        ];
1431
1432        let assembler = Assembler::with_config(
1433            &mig,
1434            AssemblerConfig {
1435                skip_unknown_segments: true,
1436                ..Default::default()
1437            },
1438        );
1439        let tree = assembler.assemble_generic(&segments).unwrap();
1440        let sg4 = &tree.groups[0];
1441        let inst = &sg4.repetitions[0];
1442
1443        // IDE + STS consumed, FOO skipped
1444        assert_eq!(inst.segments.len(), 2);
1445        assert_eq!(inst.segments[0].tag, "IDE");
1446        assert_eq!(inst.segments[1].tag, "STS");
1447        assert_eq!(inst.skipped_segments.len(), 1);
1448        assert_eq!(inst.skipped_segments[0].tag, "FOO");
1449
1450        // LOC went to nested SG5
1451        assert_eq!(inst.child_groups.len(), 1);
1452        assert_eq!(inst.child_groups[0].group_id, "SG5");
1453        assert_eq!(inst.child_groups[0].repetitions[0].segments[0].tag, "LOC");
1454    }
1455
1456    #[test]
1457    fn test_roundtrip_with_skip() {
1458        // Full roundtrip: assemble with skip → disassemble → byte-identical
1459        // including skipped segments in the output.
1460        use crate::disassembler::Disassembler;
1461        use crate::renderer::render_edifact;
1462
1463        let sg8 = make_mig_group("SG8", vec!["SEQ", "CCI"], vec![]);
1464        let mig = make_mig_schema(vec!["UNH", "UNT"], vec![sg8]);
1465
1466        let segments = vec![
1467            make_owned_seg("UNH", vec![vec!["001"]]),
1468            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1469            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1470            make_owned_seg("CCI", vec![vec!["Z30"]]),
1471            make_owned_seg("UNT", vec![vec!["4", "001"]]),
1472        ];
1473
1474        let assembler = Assembler::with_config(
1475            &mig,
1476            AssemblerConfig {
1477                skip_unknown_segments: true,
1478                ..Default::default()
1479            },
1480        );
1481        let tree = assembler.assemble_generic(&segments).unwrap();
1482
1483        let disassembler = Disassembler::new(&mig);
1484        let dis = disassembler.disassemble(&tree);
1485        let delimiters = edifact_primitives::EdifactDelimiters::default();
1486        let rendered = render_edifact(&dis, &delimiters);
1487
1488        // All 5 segments should appear in output (including skipped RFF).
1489        // Disassembler emits MIG-guided segments first (SEQ, CCI),
1490        // then skipped segments (RFF) — so order within the group differs
1491        // from the original input, but all content is preserved.
1492        assert_eq!(dis.len(), 5);
1493        assert_eq!(dis[0].tag, "UNH");
1494        assert_eq!(dis[1].tag, "SEQ");
1495        assert_eq!(dis[2].tag, "CCI");
1496        assert_eq!(dis[3].tag, "RFF"); // skipped → emitted after MIG segments
1497        assert_eq!(dis[4].tag, "UNT");
1498
1499        // Rendered output contains all segments
1500        assert!(rendered.contains("UNH+001"));
1501        assert!(rendered.contains("SEQ+Z98"));
1502        assert!(rendered.contains("RFF+Z38:REF1"));
1503        assert!(rendered.contains("CCI+Z30"));
1504        assert!(rendered.contains("UNT+4:001"));
1505    }
1506
1507    // ── Variant-aware assembly tests ──
1508
1509    #[test]
1510    fn test_variant_groups_interleaved_reps() {
1511        // Two SG8 variant definitions: one for SEQ+ZD7, one for SEQ+Z98.
1512        // Input has interleaved reps: ZD7, Z98, ZD7, Z98.
1513        // All should be collected into one SG8 group with 4 reps.
1514        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
1515        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
1516
1517        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
1518
1519        let segments = vec![
1520            make_owned_seg("UNH", vec![vec!["001"]]),
1521            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1522            make_owned_seg("CCI", vec![vec!["Z30"]]),
1523            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1524            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1525            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1526            make_owned_seg("CCI", vec![vec!["Z31"]]),
1527            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1528            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
1529        ];
1530
1531        let assembler = Assembler::new(&mig);
1532        let result = assembler.assemble_generic(&segments).unwrap();
1533
1534        assert_eq!(result.segments.len(), 1); // UNH
1535        assert_eq!(result.groups.len(), 1); // One combined SG8
1536        let sg8 = &result.groups[0];
1537        assert_eq!(sg8.group_id, "SG8");
1538        assert_eq!(sg8.repetitions.len(), 4);
1539
1540        // ZD7 reps have SEQ+CCI, Z98 reps have SEQ+RFF
1541        assert_eq!(sg8.repetitions[0].segments[0].elements[0][0], "ZD7");
1542        assert_eq!(sg8.repetitions[0].segments[1].tag, "CCI");
1543        assert_eq!(sg8.repetitions[1].segments[0].elements[0][0], "Z98");
1544        assert_eq!(sg8.repetitions[1].segments[1].tag, "RFF");
1545        assert_eq!(sg8.repetitions[2].segments[0].elements[0][0], "ZD7");
1546        assert_eq!(sg8.repetitions[3].segments[0].elements[0][0], "Z98");
1547    }
1548
1549    #[test]
1550    fn test_variant_groups_single_variant_type() {
1551        // Only Z98 reps, no ZD7 — still works with variant matching
1552        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
1553        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
1554
1555        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
1556
1557        let segments = vec![
1558            make_owned_seg("UNH", vec![vec!["001"]]),
1559            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1560            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1561            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1562            make_owned_seg("RFF", vec![vec!["Z38", "REF2"]]),
1563        ];
1564
1565        let assembler = Assembler::new(&mig);
1566        let result = assembler.assemble_generic(&segments).unwrap();
1567
1568        assert_eq!(result.groups.len(), 1);
1569        assert_eq!(result.groups[0].repetitions.len(), 2);
1570        assert_eq!(
1571            result.groups[0].repetitions[0].segments[0].elements[0][0],
1572            "Z98"
1573        );
1574        assert_eq!(
1575            result.groups[0].repetitions[1].segments[0].elements[0][0],
1576            "Z98"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_non_variant_groups_unchanged() {
1582        // Groups without variant_code behave exactly as before
1583        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
1584        let sg4 = make_mig_group("SG4", vec!["IDE", "STS"], vec![]);
1585
1586        let mig = make_mig_schema(vec!["UNH", "BGM"], vec![sg2, sg4]);
1587
1588        let segments = vec![
1589            make_owned_seg("UNH", vec![vec!["001"]]),
1590            make_owned_seg("BGM", vec![vec!["E01"]]),
1591            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1592            make_owned_seg("NAD", vec![vec!["MR", "9900456"]]),
1593            make_owned_seg("IDE", vec![vec!["24", "TX001"]]),
1594            make_owned_seg("STS", vec![vec!["7"], vec!["Z33"]]),
1595        ];
1596
1597        let assembler = Assembler::new(&mig);
1598        let result = assembler.assemble_generic(&segments).unwrap();
1599
1600        assert_eq!(result.segments.len(), 2);
1601        assert_eq!(result.groups.len(), 2);
1602        assert_eq!(result.groups[0].group_id, "SG2");
1603        assert_eq!(result.groups[0].repetitions.len(), 2);
1604        assert_eq!(result.groups[1].group_id, "SG4");
1605        assert_eq!(result.groups[1].repetitions.len(), 1);
1606    }
1607
1608    #[test]
1609    fn test_variant_groups_with_nested_children() {
1610        // Variant groups can have nested child groups
1611        let sg10 = make_mig_group("SG10", vec!["CCI", "CAV"], vec![]);
1612        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10.clone()], "ZD7");
1613        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ"], vec![sg10], "Z98");
1614
1615        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7, sg8_z98]);
1616
1617        let segments = vec![
1618            make_owned_seg("UNH", vec![vec!["001"]]),
1619            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1620            make_owned_seg("CCI", vec![vec!["Z30"]]),
1621            make_owned_seg("CAV", vec![vec!["Z91", "Y"]]),
1622            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1623            make_owned_seg("CCI", vec![vec!["Z31"]]),
1624            make_owned_seg("CAV", vec![vec!["Z91", "N"]]),
1625        ];
1626
1627        let assembler = Assembler::new(&mig);
1628        let result = assembler.assemble_generic(&segments).unwrap();
1629
1630        assert_eq!(result.groups.len(), 1);
1631        let sg8 = &result.groups[0];
1632        assert_eq!(sg8.repetitions.len(), 2);
1633
1634        // First rep (ZD7) has nested SG10
1635        assert_eq!(sg8.repetitions[0].child_groups.len(), 1);
1636        assert_eq!(sg8.repetitions[0].child_groups[0].group_id, "SG10");
1637        assert_eq!(
1638            sg8.repetitions[0].child_groups[0].repetitions[0].segments[0].elements[0][0],
1639            "Z30"
1640        );
1641
1642        // Second rep (Z98) has nested SG10
1643        assert_eq!(sg8.repetitions[1].child_groups.len(), 1);
1644        assert_eq!(
1645            sg8.repetitions[1].child_groups[0].repetitions[0].segments[0].elements[0][0],
1646            "Z31"
1647        );
1648    }
1649
1650    #[test]
1651    fn test_variant_qualifier_check_prevents_wrong_variant_consumption() {
1652        // try_consume_group with variant_code set should NOT consume a segment
1653        // whose qualifier doesn't match, even if the tag matches.
1654        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
1655
1656        let mig = make_mig_schema(vec!["UNH"], vec![sg8_zd7]);
1657
1658        let segments = vec![
1659            make_owned_seg("UNH", vec![vec!["001"]]),
1660            make_owned_seg("SEQ", vec![vec!["Z98"]]), // Wrong qualifier
1661            make_owned_seg("CCI", vec![vec!["Z30"]]),
1662        ];
1663
1664        let assembler = Assembler::new(&mig);
1665        let result = assembler.assemble_generic(&segments).unwrap();
1666
1667        // SG8 should have no reps because Z98 != ZD7
1668        assert!(result.groups.is_empty());
1669    }
1670
1671    #[test]
1672    fn test_mixed_variant_and_non_variant_groups() {
1673        // SG2 (no variant), then variant SG8s, then SG12 (no variant)
1674        let sg2 = make_mig_group("SG2", vec!["NAD"], vec![]);
1675        let sg8_zd7 = make_mig_group_with_variant("SG8", vec!["SEQ", "CCI"], vec![], "ZD7");
1676        let sg8_z98 = make_mig_group_with_variant("SG8", vec!["SEQ", "RFF"], vec![], "Z98");
1677        let sg12 = make_mig_group("SG12", vec!["NAD"], vec![]);
1678
1679        let mig = make_mig_schema(vec!["UNH"], vec![sg2, sg8_zd7, sg8_z98, sg12]);
1680
1681        let segments = vec![
1682            make_owned_seg("UNH", vec![vec!["001"]]),
1683            make_owned_seg("NAD", vec![vec!["MS", "9900123"]]),
1684            make_owned_seg("SEQ", vec![vec!["ZD7"]]),
1685            make_owned_seg("CCI", vec![vec!["Z30"]]),
1686            make_owned_seg("SEQ", vec![vec!["Z98"]]),
1687            make_owned_seg("RFF", vec![vec!["Z38", "REF1"]]),
1688            make_owned_seg("NAD", vec![vec!["Z65", "ID001"]]),
1689        ];
1690
1691        let assembler = Assembler::new(&mig);
1692        let result = assembler.assemble_generic(&segments).unwrap();
1693
1694        assert_eq!(result.groups.len(), 3); // SG2, SG8 (combined), SG12
1695        assert_eq!(result.groups[0].group_id, "SG2");
1696        assert_eq!(result.groups[0].repetitions.len(), 1);
1697        assert_eq!(result.groups[1].group_id, "SG8");
1698        assert_eq!(result.groups[1].repetitions.len(), 2);
1699        assert_eq!(result.groups[2].group_id, "SG12");
1700        assert_eq!(result.groups[2].repetitions.len(), 1);
1701    }
1702
1703    #[test]
1704    fn test_assembler_disambiguates_shared_qualifier_by_full_code_profile() {
1705        // PID 55035 PIA variants: several mig slots share the primary qualifier
1706        // 4347='5' but differ at C212/7143 (one allows Z12, another SRW). With
1707        // only per-mig qualifier_map, the assembler consumes at the first matching
1708        // slot regardless of the composite code — the downstream validator is
1709        // then forced to second-guess the variant choice. Disambiguate at
1710        // assembly time by checking all code-bearing positions declared on the
1711        // MIG segment.
1712        use mig_types::schema::common::CodeDefinition;
1713        use mig_types::schema::mig::{MigComposite, MigDataElement};
1714        use std::collections::HashMap;
1715
1716        fn code(value: &str) -> CodeDefinition {
1717            CodeDefinition {
1718                value: value.to_string(),
1719                name: value.to_string(),
1720                description: None,
1721            }
1722        }
1723
1724        fn pia_slot(number: &str, composite_code: &str) -> MigSegment {
1725            MigSegment {
1726                id: "PIA".to_string(),
1727                name: "PIA".to_string(),
1728                description: None,
1729                counter: None,
1730                level: 1,
1731                number: Some(number.to_string()),
1732                max_rep_std: 1,
1733                max_rep_spec: 1,
1734                status_std: Some("M".to_string()),
1735                status_spec: Some("M".to_string()),
1736                example: None,
1737                data_elements: vec![MigDataElement {
1738                    id: "4347".to_string(),
1739                    name: "Produkt-ID-Funktion".to_string(),
1740                    description: None,
1741                    status_std: Some("M".to_string()),
1742                    status_spec: Some("M".to_string()),
1743                    format_std: None,
1744                    format_spec: None,
1745                    codes: vec![code("5")],
1746                    position: 0,
1747                }],
1748                composites: vec![MigComposite {
1749                    id: "C212".to_string(),
1750                    name: "Item Identifier".to_string(),
1751                    description: None,
1752                    status_std: Some("M".to_string()),
1753                    status_spec: Some("M".to_string()),
1754                    data_elements: vec![MigDataElement {
1755                        id: "7143".to_string(),
1756                        name: "Artikel/Dienstleistung-ID".to_string(),
1757                        description: None,
1758                        status_std: Some("M".to_string()),
1759                        status_spec: Some("M".to_string()),
1760                        format_std: None,
1761                        format_spec: None,
1762                        codes: vec![code(composite_code)],
1763                        position: 0,
1764                    }],
1765                    position: 1,
1766                }],
1767            }
1768        }
1769
1770        let sg4 = MigSegmentGroup {
1771            segments: vec![
1772                crate::test_support::make_mig_segment_numbered("IDE", "00020"),
1773                pia_slot("00108", "Z12"),
1774                pia_slot("00197", "SRW"),
1775            ],
1776            ..make_mig_group("SG4", vec![], vec![])
1777        };
1778        let mig = make_mig_schema(vec!["UNH"], vec![sg4]);
1779
1780        let segments = vec![
1781            make_owned_seg("UNH", vec![vec!["001"]]),
1782            make_owned_seg("IDE", vec![vec!["24"]]),
1783            // PIA+5+:::SRW — composite element 1, component 0 = "SRW"
1784            make_owned_seg("PIA", vec![vec!["5"], vec!["SRW"]]),
1785        ];
1786
1787        // Both PIA mig slots share the qualifier (0,0)='5'. Without full-profile
1788        // matching, the first slot (00108) wins and the SRW composite is
1789        // mis-assigned to the Z12 variant.
1790        let mut qualifier_map = HashMap::new();
1791        qualifier_map.insert("00108".to_string(), (0, 0, "5".to_string()));
1792        qualifier_map.insert("00197".to_string(), (0, 0, "5".to_string()));
1793
1794        let config = AssemblerConfig {
1795            skip_unknown_segments: false,
1796            qualifier_map,
1797            strict_code_matching: true,
1798        };
1799        let assembler = Assembler::with_config(&mig, config);
1800        let tree = assembler.assemble_generic(&segments).unwrap();
1801
1802        let sg4_instance = &tree.groups[0].repetitions[0];
1803        let pia = sg4_instance
1804            .segments
1805            .iter()
1806            .find(|s| s.tag == "PIA")
1807            .expect("PIA consumed into SG4");
1808        assert_eq!(
1809            pia.mig_number.as_deref(),
1810            Some("00197"),
1811            "PIA+5+:::SRW must be assigned the SRW variant (mig=00197), not the Z12 variant"
1812        );
1813    }
1814}