Skip to main content

mig_bo4e/
code_lookup.rs

1//! Code enrichment lookup — maps EDIFACT companion field codes to human-readable meanings.
2//!
3//! Built from PID schema JSON files. Used by the mapping engine to automatically
4//! enrich companion field values during forward mapping (EDIFACT → BO4E).
5
6use serde_json::Value;
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::Path;
9
10/// Lookup key: (source_path, segment_tag, qualifier, element_index, component_index).
11///
12/// `source_path` matches the TOML `source_path` field (e.g., "sg4.sg8_z01.sg10").
13/// `segment_tag` is uppercase (e.g., "CCI", "CAV").
14/// `qualifier` is the segment's discriminating qualifier when one applies (RFF/STS/CCI:
15/// element 0 component 0; DTM: c507.d2005). `None` for segments without a qualifier
16/// convention. The qualifier slot scopes lookups so that, for instance, RFF+TN's
17/// type=data d1154 (free-text Vorgangsnummer) is not confused with RFF+Z13's
18/// type=code d1154 (PID-identifier) at the same path/elem/comp.
19pub type CodeLookupKey = (String, String, Option<String>, usize, usize);
20
21/// Enrichment data for a single EDIFACT code value.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct CodeEnrichment {
24    pub meaning: String,
25    pub enum_key: Option<String>,
26}
27
28/// Maps EDIFACT code values to their enrichment data (meaning + optional enum key).
29/// E.g., "Z15" → CodeEnrichment { meaning: "Haushaltskunde gem. EnWG", enum_key: Some("HAUSHALTSKUNDE_ENWG") }.
30pub type CodeMeanings = BTreeMap<String, CodeEnrichment>;
31
32/// Complete code lookup table built from a PID schema JSON.
33///
34/// Entries are scoped by the segment variant's qualifier. Tags with a qualifier
35/// convention (RFF/STS/CCI/DTM) are stored *only* under their qualifier. Other
36/// tags (NAD, CAV, FTX, …) are stored under `None` (the union of all same-tag
37/// segments, for unqualified lookups) and additionally under their own leading
38/// code when the schema fixes it to one value (e.g. `CAV+Z91`), so a qualified
39/// lookup (`cav[Z91]`) sees only the codes of that segment variant.
40#[derive(Debug, Clone, Default)]
41pub struct CodeLookup {
42    entries: BTreeMap<CodeLookupKey, CodeMeanings>,
43    /// `(source_path, segment_tag, qualifier)` of every qualifier-scoped entry.
44    /// Derived from `entries` (not serialized). A qualifier listed here names a
45    /// known segment variant, whose entries are authoritative: lookups for it
46    /// never fall back to the unqualified union.
47    variants: HashSet<(String, String, String)>,
48}
49
50// Custom serialization: convert tuple keys to "source_path|segment_tag|qualifier|elem|comp"
51// strings. An empty qualifier slot serializes as the empty string between the surrounding
52// pipes (e.g., "sg4|DTM||0|0"). Entries are written sorted by that key so the committed
53// cache files are byte-stable.
54impl serde::Serialize for CodeLookup {
55    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
56        use serde::ser::SerializeMap;
57        let mut entries: Vec<(String, &CodeMeanings)> = self
58            .entries
59            .iter()
60            .map(|((path, tag, qual, elem, comp), meanings)| {
61                let q = qual.as_deref().unwrap_or("");
62                (format!("{path}|{tag}|{q}|{elem}|{comp}"), meanings)
63            })
64            .collect();
65        entries.sort_by(|a, b| a.0.cmp(&b.0));
66        let mut map = serializer.serialize_map(Some(entries.len()))?;
67        for (key, meanings) in entries {
68            map.serialize_entry(&key, meanings)?;
69        }
70        map.end()
71    }
72}
73
74impl<'de> serde::Deserialize<'de> for CodeLookup {
75    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
76        let raw: HashMap<String, CodeMeanings> = HashMap::deserialize(deserializer)?;
77        let mut entries = BTreeMap::new();
78        for (key_str, meanings) in raw {
79            let parts: Vec<&str> = key_str.splitn(5, '|').collect();
80            if parts.len() == 5 {
81                let qual = if parts[2].is_empty() {
82                    None
83                } else {
84                    Some(parts[2].to_string())
85                };
86                let elem: usize = parts[3].parse().map_err(serde::de::Error::custom)?;
87                let comp: usize = parts[4].parse().map_err(serde::de::Error::custom)?;
88                entries.insert(
89                    (parts[0].to_string(), parts[1].to_string(), qual, elem, comp),
90                    meanings,
91                );
92            }
93        }
94        Ok(Self::from_entries(entries))
95    }
96}
97
98impl CodeLookup {
99    /// Build a CodeLookup from a PID schema JSON file.
100    pub fn from_schema_file(path: &Path) -> Result<Self, std::io::Error> {
101        let content = std::fs::read_to_string(path)?;
102        let schema: Value = serde_json::from_str(&content)
103            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
104        Ok(Self::from_schema_value(&schema))
105    }
106
107    /// Build a CodeLookup from an already-parsed PID schema JSON value.
108    pub fn from_schema_value(schema: &Value) -> Self {
109        let mut entries = BTreeMap::new();
110        if let Some(fields) = schema.get("fields").and_then(|f| f.as_object()) {
111            for (group_key, group_value) in fields {
112                Self::walk_group(group_key, group_value, &mut entries);
113            }
114        }
115        // Root-level segments (BGM, DTM, etc.) use empty source_path.
116        if let Some(root_segments) = schema.get("root_segments").and_then(|s| s.as_array()) {
117            for segment in root_segments {
118                let seg_id = segment
119                    .get("id")
120                    .and_then(|v| v.as_str())
121                    .unwrap_or("")
122                    .to_uppercase();
123                Self::process_segment("", &seg_id, segment, &mut entries);
124            }
125        }
126        Self::from_entries(entries)
127    }
128
129    fn from_entries(entries: BTreeMap<CodeLookupKey, CodeMeanings>) -> Self {
130        let variants = entries
131            .keys()
132            .filter_map(|(path, tag, qual, _, _)| {
133                qual.as_ref()
134                    .map(|q| (path.clone(), tag.clone(), q.clone()))
135            })
136            .collect();
137        Self { entries, variants }
138    }
139
140    /// Codes a mapped field is enriched with (`{code, meaning}`), `None` if the
141    /// engine writes it as a plain value.
142    ///
143    /// `path_qualifier` is the field path's `tag[Q]` selector, `disc_qualifier`
144    /// the definition's discriminator value when the discriminator is on the same
145    /// segment tag. Which positions get enriched follows the discriminator (as it
146    /// always has); a path qualifier only narrows the codes to the segment variant
147    /// the field actually reads, and suppresses enrichment where that variant has
148    /// no code at the position (e.g. `rff[ACW].c506.d1154` next to RFF+Z13).
149    pub fn enrichment_codes(
150        &self,
151        source_path: &str,
152        segment_tag: &str,
153        path_qualifier: Option<&str>,
154        disc_qualifier: Option<&str>,
155        element_index: usize,
156        component_index: usize,
157    ) -> Option<&CodeMeanings> {
158        let at = |q| self.resolve_q(source_path, segment_tag, q, element_index, component_index);
159        let path_variant =
160            path_qualifier.filter(|q| self.is_known_variant(source_path, segment_tag, q));
161        match (path_variant, disc_qualifier) {
162            (Some(p), Some(d)) if p != d => at(Some(p)),
163            (Some(p), None) => at(None).and(at(Some(p))),
164            _ => at(disc_qualifier),
165        }
166    }
167
168    /// Codes a mapped field can hold, whether enriched or not: those of the
169    /// segment variant selected by the path qualifier or the same-tag
170    /// discriminator; without either, the union over every variant (the field
171    /// reads whichever segment instance is present). `None` if not a code field.
172    pub fn field_codes(
173        &self,
174        source_path: &str,
175        segment_tag: &str,
176        path_qualifier: Option<&str>,
177        disc_qualifier: Option<&str>,
178        element_index: usize,
179        component_index: usize,
180    ) -> Option<CodeMeanings> {
181        match path_qualifier.or(disc_qualifier) {
182            Some(q) => self
183                .resolve_q(
184                    source_path,
185                    segment_tag,
186                    Some(q),
187                    element_index,
188                    component_index,
189                )
190                .cloned(),
191            None => Some(self.codes_all_qualifiers(
192                source_path,
193                segment_tag,
194                element_index,
195                component_index,
196            ))
197            .filter(|c| !c.is_empty()),
198        }
199    }
200
201    /// Whether `qualifier` names a segment variant of `segment_tag` at `source_path`
202    /// (i.e. the schema has a `segment_tag` segment whose leading code is fixed to it).
203    pub fn is_known_variant(&self, source_path: &str, segment_tag: &str, qualifier: &str) -> bool {
204        self.variants.contains(&(
205            source_path.to_string(),
206            segment_tag.to_string(),
207            qualifier.to_string(),
208        ))
209    }
210
211    /// The entry a qualifier-aware lookup resolves to: the qualifier's own entry;
212    /// for a qualifier that names no segment variant of the tag (or no qualifier),
213    /// the unqualified entry. A known variant never falls back to the unqualified
214    /// union — that would hand it the codes of a *different* segment variant (e.g.
215    /// RFF+Z13's PID code for RFF+ACW's free reference number).
216    fn resolve_q(
217        &self,
218        source_path: &str,
219        segment_tag: &str,
220        qualifier: Option<&str>,
221        element_index: usize,
222        component_index: usize,
223    ) -> Option<&CodeMeanings> {
224        let key = |q: Option<&str>| {
225            (
226                source_path.to_string(),
227                segment_tag.to_string(),
228                q.map(String::from),
229                element_index,
230                component_index,
231            )
232        };
233        match qualifier {
234            Some(q) if self.is_known_variant(source_path, segment_tag, q) => {
235                self.entries.get(&key(Some(q)))
236            }
237            Some(q) => self
238                .entries
239                .get(&key(Some(q)))
240                .or_else(|| self.entries.get(&key(None))),
241            None => self.entries.get(&key(None)),
242        }
243    }
244
245    /// Check if the field at the given position is a code-type field.
246    ///
247    /// Legacy shim — scans across all qualifier slots and returns true if ANY
248    /// matching entry exists for the (path, tag, elem, comp) tuple. This drifts
249    /// from the original "call _q with None" prescription but is more useful
250    /// for tests that don't have a qualifier handy. Production code paths use
251    /// [`is_code_field_q`] with the discriminator qualifier and a `None`
252    /// fallback for tags without a stored qualifier convention.
253    #[deprecated(
254        note = "use is_code_field_q with the discriminator qualifier; this shim scans across all qualifiers"
255    )]
256    pub fn is_code_field(
257        &self,
258        source_path: &str,
259        segment_tag: &str,
260        element_index: usize,
261        component_index: usize,
262    ) -> bool {
263        // Match if either the unqualified entry exists or any qualifier-scoped
264        // entry matches the path/tag/elem/comp.
265        self.entries.iter().any(|((p, t, _q, e, c), _)| {
266            p == source_path && t == segment_tag && *e == element_index && *c == component_index
267        })
268    }
269
270    /// Qualifier-aware variant: check if the position is a code field for the
271    /// given qualifier.
272    ///
273    /// When `qualifier` names a segment variant of the tag (see
274    /// [`is_known_variant`](Self::is_known_variant)), only that variant's entry
275    /// counts: RFF+TN d1154 (data) is not a code field even though RFF+Z13 d1154
276    /// is. A qualifier that names no variant (e.g. a discriminator value of
277    /// another segment) and `None` use the unqualified entry.
278    pub fn is_code_field_q(
279        &self,
280        source_path: &str,
281        segment_tag: &str,
282        qualifier: Option<&str>,
283        element_index: usize,
284        component_index: usize,
285    ) -> bool {
286        self.resolve_q(
287            source_path,
288            segment_tag,
289            qualifier,
290            element_index,
291            component_index,
292        )
293        .is_some()
294    }
295
296    /// All codes (with their enrichment data) of the code field at the given
297    /// position, resolved exactly like [`is_code_field_q`]. `None` if the
298    /// position is not a code field (for that segment variant).
299    ///
300    /// [`is_code_field_q`]: Self::is_code_field_q
301    pub fn codes_q(
302        &self,
303        source_path: &str,
304        segment_tag: &str,
305        qualifier: Option<&str>,
306        element_index: usize,
307        component_index: usize,
308    ) -> Option<&CodeMeanings> {
309        self.resolve_q(
310            source_path,
311            segment_tag,
312            qualifier,
313            element_index,
314            component_index,
315        )
316    }
317
318    /// Codes of the given position under every qualifier slot, merged. A
319    /// definition without a discriminator reads whichever segment instance is
320    /// present, so its values range over all of them.
321    pub fn codes_all_qualifiers(
322        &self,
323        source_path: &str,
324        segment_tag: &str,
325        element_index: usize,
326        component_index: usize,
327    ) -> CodeMeanings {
328        // Deterministic merge: the unqualified slot first, then by qualifier.
329        let mut slots: Vec<(&Option<String>, &CodeMeanings)> = self
330            .entries
331            .iter()
332            .filter(|((p, t, _, e, c), _)| {
333                p == source_path && t == segment_tag && *e == element_index && *c == component_index
334            })
335            .map(|((_, _, q, _, _), meanings)| (q, meanings))
336            .collect();
337        slots.sort_by(|a, b| a.0.cmp(b.0));
338        let mut merged = CodeMeanings::new();
339        for (_, meanings) in slots {
340            for (code, enrichment) in meanings {
341                merged
342                    .entry(code.clone())
343                    .or_insert_with(|| enrichment.clone());
344            }
345        }
346        merged
347    }
348
349    /// Get the full enrichment data for a code value at the given position.
350    ///
351    /// Legacy shim — scans across all qualifier slots. See [`enrichment_for_q`]
352    /// for the qualifier-aware version used by the engine. Kept for tests.
353    #[deprecated(
354        note = "use enrichment_for_q with the discriminator qualifier; this shim scans across all qualifiers"
355    )]
356    pub fn enrichment_for(
357        &self,
358        source_path: &str,
359        segment_tag: &str,
360        element_index: usize,
361        component_index: usize,
362        value: &str,
363    ) -> Option<&CodeEnrichment> {
364        // Try unqualified first, then any qualifier-scoped match.
365        let unqualified_key = (
366            source_path.to_string(),
367            segment_tag.to_string(),
368            None,
369            element_index,
370            component_index,
371        );
372        if let Some(e) = self
373            .entries
374            .get(&unqualified_key)
375            .and_then(|meanings| meanings.get(value))
376        {
377            return Some(e);
378        }
379        self.entries
380            .iter()
381            .filter(|((p, t, q, e, c), _)| {
382                p == source_path
383                    && t == segment_tag
384                    && q.is_some()
385                    && *e == element_index
386                    && *c == component_index
387            })
388            .find_map(|(_, meanings)| meanings.get(value))
389    }
390
391    /// Qualifier-aware enrichment lookup, resolved like
392    /// [`is_code_field_q`](Self::is_code_field_q).
393    pub fn enrichment_for_q(
394        &self,
395        source_path: &str,
396        segment_tag: &str,
397        qualifier: Option<&str>,
398        element_index: usize,
399        component_index: usize,
400        value: &str,
401    ) -> Option<&CodeEnrichment> {
402        self.resolve_q(
403            source_path,
404            segment_tag,
405            qualifier,
406            element_index,
407            component_index,
408        )
409        .and_then(|meanings| meanings.get(value))
410    }
411
412    /// Get the human-readable meaning for a code value at the given position.
413    /// Returns `None` if the position is not a code field or the value is unknown.
414    ///
415    /// Legacy shim — scans across all qualifier slots via [`enrichment_for`].
416    /// Kept for tests; production code paths use the qualifier-aware
417    /// `enrichment_for_q`.
418    #[deprecated(
419        note = "use enrichment_for_q with the discriminator qualifier; this shim scans across all qualifiers"
420    )]
421    pub fn meaning_for(
422        &self,
423        source_path: &str,
424        segment_tag: &str,
425        element_index: usize,
426        component_index: usize,
427        value: &str,
428    ) -> Option<&str> {
429        #[allow(deprecated)]
430        self.enrichment_for(
431            source_path,
432            segment_tag,
433            element_index,
434            component_index,
435            value,
436        )
437        .map(|e| e.meaning.as_str())
438    }
439
440    /// Whether this code-field's only allowed value equals the given PID.
441    /// Used to suppress decoration for self-referential PID-identifier fields
442    /// (Class C in the 2026-04-28 audit). The qualifier scopes the lookup —
443    /// e.g., RFF+Z13's d1154 in PID 55002 has `value=55002` as the lone code,
444    /// so calling with `qualifier=Some("Z13"), pid="55002"` returns true.
445    pub fn is_pid_self_reference(
446        &self,
447        source_path: &str,
448        segment_tag: &str,
449        qualifier: Option<&str>,
450        element_index: usize,
451        component_index: usize,
452        pid: &str,
453    ) -> bool {
454        let key = (
455            source_path.to_string(),
456            segment_tag.to_string(),
457            qualifier.map(String::from),
458            element_index,
459            component_index,
460        );
461        if let Some(meanings) = self.entries.get(&key) {
462            meanings.len() == 1 && meanings.contains_key(pid)
463        } else {
464            false
465        }
466    }
467
468    /// Walk a group node recursively, collecting code entries.
469    fn walk_group(
470        path_prefix: &str,
471        group: &Value,
472        entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
473    ) {
474        if let Some(segments) = group.get("segments").and_then(|s| s.as_array()) {
475            for segment in segments {
476                let seg_id = segment
477                    .get("id")
478                    .and_then(|v| v.as_str())
479                    .unwrap_or("")
480                    .to_uppercase();
481                Self::process_segment(path_prefix, &seg_id, segment, entries);
482            }
483        }
484        if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
485            for (child_key, child_value) in children {
486                let child_path = format!("{}.{}", path_prefix, child_key);
487                Self::walk_group(&child_path, child_value, entries);
488            }
489            // Create aggregate entries at the base path for discriminated variants.
490            // E.g., sg12_z63, sg12_z65, sg12_z66 → also register at sg12 (unioned codes).
491            // This supports TOMLs using non-discriminated source_path (e.g., "sg4.sg12").
492            Self::merge_variant_entries(path_prefix, children, entries);
493        }
494    }
495
496    /// Process a single segment, collecting code entries for its elements/components.
497    ///
498    /// Extracts the segment's qualifier (per-tag convention) and uses it to scope
499    /// the entries. This avoids the (path, tag, elem, comp) collision between
500    /// type=code and type=data segments at the same position (e.g., RFF+Z13's
501    /// PID-identifier d1154 vs RFF+TN's free-text Vorgangsnummer d1154).
502    fn process_segment(
503        source_path: &str,
504        segment_tag: &str,
505        segment: &Value,
506        entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
507    ) {
508        let Some(elements) = segment.get("elements").and_then(|e| e.as_array()) else {
509            return;
510        };
511
512        let qualifier = Self::extract_qualifier(segment_tag, elements);
513        // Tags without a qualifier convention keep their unqualified entries and
514        // are additionally registered under their own fixed leading code.
515        let own_variant = if qualifier.is_none() && !Self::has_qualifier_convention(segment_tag) {
516            Self::single_leading_code(elements)
517        } else {
518            None
519        };
520        let slots: Vec<Option<String>> = std::iter::once(qualifier)
521            .chain(own_variant.map(Some))
522            .collect();
523
524        for element in elements {
525            let element_index = element.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
526
527            // Simple element (no composite) with codes
528            if let Some("code") = element.get("type").and_then(|v| v.as_str()) {
529                if let Some(codes) = element.get("codes").and_then(|c| c.as_array()) {
530                    let meanings = Self::extract_codes(codes);
531                    if !meanings.is_empty() {
532                        for slot in &slots {
533                            let key = (
534                                source_path.to_string(),
535                                segment_tag.to_string(),
536                                slot.clone(),
537                                element_index,
538                                0,
539                            );
540                            entries.entry(key).or_default().extend(meanings.clone());
541                        }
542                    }
543                }
544            }
545
546            // Composite components
547            if let Some(components) = element.get("components").and_then(|c| c.as_array()) {
548                for component in components {
549                    if let Some("code") = component.get("type").and_then(|v| v.as_str()) {
550                        let sub_index = component
551                            .get("sub_index")
552                            .and_then(|v| v.as_u64())
553                            .unwrap_or(0) as usize;
554                        if let Some(codes) = component.get("codes").and_then(|c| c.as_array()) {
555                            let meanings = Self::extract_codes(codes);
556                            if !meanings.is_empty() {
557                                for slot in &slots {
558                                    let key = (
559                                        source_path.to_string(),
560                                        segment_tag.to_string(),
561                                        slot.clone(),
562                                        element_index,
563                                        sub_index,
564                                    );
565                                    entries.entry(key).or_default().extend(meanings.clone());
566                                }
567                            }
568                        }
569                    }
570                }
571            }
572        }
573    }
574
575    /// Extract a segment's discriminating qualifier from its schema element list.
576    ///
577    /// Conventions:
578    /// - `RFF`, `STS`, `CCI`: qualifier is the type=code value at element 0,
579    ///   component 0 (RFF d1153, STS d9013, CCI d7059).
580    /// - `DTM`: qualifier is at composite c507's component 0 (d2005). This is
581    ///   the same physical position (element 0 component 0) — DTM's element 0
582    ///   IS the c507 composite — so the same lookup applies.
583    /// - All other tags: no qualifier convention; returns `None`.
584    ///
585    /// Only single-value enumerations count as a qualifier (the schema lists
586    /// exactly one allowed code at that position). Segments whose first
587    /// component lists multiple codes don't have a discriminating qualifier
588    /// at the schema level and fall back to `None`.
589    fn extract_qualifier(segment_tag: &str, elements: &[Value]) -> Option<String> {
590        if !Self::has_qualifier_convention(segment_tag) {
591            return None;
592        }
593        Self::single_leading_code(elements)
594    }
595
596    /// Tags whose entries are stored exclusively under their qualifier.
597    fn has_qualifier_convention(segment_tag: &str) -> bool {
598        matches!(segment_tag, "RFF" | "STS" | "CCI" | "DTM")
599    }
600
601    /// The segment's leading code (element 0, component 0) when the schema fixes
602    /// it to exactly one value — the value a `tag[QUAL]` field path selects on.
603    fn single_leading_code(elements: &[Value]) -> Option<String> {
604        // Find element index 0 (or the first element if no index 0 is set).
605        let element0 = elements
606            .iter()
607            .find(|el| el.get("index").and_then(|v| v.as_u64()) == Some(0))
608            .or_else(|| elements.first())?;
609
610        // Inspect component sub_index 0.
611        let component0 = element0
612            .get("components")
613            .and_then(|c| c.as_array())
614            .and_then(|comps| {
615                comps
616                    .iter()
617                    .find(|c| c.get("sub_index").and_then(|v| v.as_u64()) == Some(0))
618                    .or_else(|| comps.first())
619            });
620
621        let codes_node = if let Some(comp) = component0 {
622            // Composite case (RFF/DTM/CCI/STS — qualifier nested inside composite).
623            if comp.get("type").and_then(|v| v.as_str()) == Some("code") {
624                comp.get("codes").and_then(|c| c.as_array())
625            } else {
626                None
627            }
628        } else if element0.get("type").and_then(|v| v.as_str()) == Some("code") {
629            // Simple-element case.
630            element0.get("codes").and_then(|c| c.as_array())
631        } else {
632            None
633        };
634
635        let codes = codes_node?;
636        if codes.len() != 1 {
637            return None; // Multiple allowed qualifiers — not a single discriminator.
638        }
639        codes[0]
640            .get("value")
641            .and_then(|v| v.as_str())
642            .map(|s| s.to_string())
643    }
644
645    /// Merge code entries from discriminated variant children into aggregate base-path entries.
646    ///
647    /// When the schema has `sg12_z63`, `sg12_z65`, etc., each gets its own CodeLookup entries
648    /// at `prefix.sg12_z63`, `prefix.sg12_z65`. This method also creates entries at the
649    /// base path `prefix.sg12` by unioning all codes from the variants. This supports
650    /// TOMLs that use a non-discriminated `source_path` (e.g., the Geschaeftspartner pattern).
651    fn merge_variant_entries(
652        path_prefix: &str,
653        children: &serde_json::Map<String, Value>,
654        entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
655    ) {
656        // Group children by base name (part before '_'): sg12_z63 → sg12
657        let mut bases: HashMap<&str, Vec<&str>> = HashMap::new();
658        for child_key in children.keys() {
659            if let Some(underscore_pos) = child_key.find('_') {
660                let base = &child_key[..underscore_pos];
661                bases.entry(base).or_default().push(child_key);
662            }
663        }
664
665        for (base, variant_keys) in &bases {
666            if variant_keys.len() < 2 {
667                continue; // Not a discriminated group
668            }
669            let base_path = format!("{}.{}", path_prefix, base);
670            // Collect all variant-path entries and merge into base-path entries.
671            // Aggregation key keeps the qualifier slot so that, e.g., NAD+Z63 vs
672            // NAD+Z65 don't collapse into one entry at the merged base path.
673            let mut merged: HashMap<(String, Option<String>, usize, usize), CodeMeanings> =
674                HashMap::new();
675            for variant_key in variant_keys {
676                let variant_path = format!("{}.{}", path_prefix, variant_key);
677                for (key, meanings) in entries.iter() {
678                    if key.0 == variant_path {
679                        let agg_key = (key.1.clone(), key.2.clone(), key.3, key.4);
680                        let target = merged.entry(agg_key).or_default();
681                        for (k, v) in meanings {
682                            target.insert(k.clone(), v.clone());
683                        }
684                    }
685                }
686            }
687            for ((seg_tag, qual, elem_idx, comp_idx), meanings) in merged {
688                let key = (base_path.clone(), seg_tag, qual, elem_idx, comp_idx);
689                entries.entry(key).or_default().extend(meanings);
690            }
691        }
692    }
693
694    /// Extract code value→enrichment mappings from a JSON codes array.
695    fn extract_codes(codes: &[Value]) -> CodeMeanings {
696        let mut meanings = BTreeMap::new();
697        for code in codes {
698            if let (Some(value), Some(name)) = (
699                code.get("value").and_then(|v| v.as_str()),
700                code.get("name").and_then(|v| v.as_str()),
701            ) {
702                let enum_key = code
703                    .get("enum")
704                    .and_then(|v| v.as_str())
705                    .map(|s| s.to_string());
706                meanings.insert(
707                    value.to_string(),
708                    CodeEnrichment {
709                        meaning: name.to_string(),
710                        enum_key,
711                    },
712                );
713            }
714        }
715        meanings
716    }
717}
718
719#[cfg(test)]
720#[allow(deprecated)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn test_parse_pid_55001_schema() {
726        let schema_path = Path::new(concat!(
727            env!("CARGO_MANIFEST_DIR"),
728            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
729        ));
730        if !schema_path.exists() {
731            eprintln!("Skipping: PID schema not found");
732            return;
733        }
734
735        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
736
737        // CCI element 2 component 0 in sg4.sg8_z01.sg10 — Haushaltskunde codes
738        assert!(lookup.is_code_field("sg4.sg8_z01.sg10", "CCI", 2, 0));
739        assert_eq!(
740            lookup.meaning_for("sg4.sg8_z01.sg10", "CCI", 2, 0, "Z15"),
741            Some("Haushaltskunde gem. EnWG")
742        );
743        assert_eq!(
744            lookup.meaning_for("sg4.sg8_z01.sg10", "CCI", 2, 0, "Z18"),
745            Some("Kein Haushaltskunde gem. EnWG")
746        );
747
748        // CCI element 0 in sg4.sg8_z79.sg10 — Produkteigenschaft
749        assert!(lookup.is_code_field("sg4.sg8_z79.sg10", "CCI", 0, 0));
750        assert_eq!(
751            lookup.meaning_for("sg4.sg8_z79.sg10", "CCI", 0, 0, "Z66"),
752            Some("Produkteigenschaft")
753        );
754
755        // CAV element 0 component 0 — code field
756        assert!(lookup.is_code_field("sg4.sg8_z79.sg10", "CAV", 0, 0));
757
758        // CAV element 0 component 3 — data field, NOT a code
759        assert!(!lookup.is_code_field("sg4.sg8_z79.sg10", "CAV", 0, 3));
760
761        // LOC element 1 — data field
762        assert!(!lookup.is_code_field("sg4.sg5_z16", "LOC", 1, 0));
763    }
764
765    #[test]
766    fn test_from_inline_schema() {
767        let schema = serde_json::json!({
768            "fields": {
769                "sg4": {
770                    "children": {
771                        "sg8_test": {
772                            "children": {
773                                "sg10": {
774                                    "segments": [{
775                                        "id": "CCI",
776                                        "elements": [{
777                                            "index": 2,
778                                            "components": [{
779                                                "sub_index": 0,
780                                                "type": "code",
781                                                "codes": [
782                                                    {"value": "A1", "name": "Alpha"},
783                                                    {"value": "B2", "name": "Beta"}
784                                                ]
785                                            }]
786                                        }]
787                                    }],
788                                    "source_group": "SG10"
789                                }
790                            },
791                            "segments": [],
792                            "source_group": "SG8"
793                        }
794                    },
795                    "segments": [],
796                    "source_group": "SG4"
797                }
798            }
799        });
800
801        let lookup = CodeLookup::from_schema_value(&schema);
802
803        assert!(lookup.is_code_field("sg4.sg8_test.sg10", "CCI", 2, 0));
804        assert_eq!(
805            lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "A1"),
806            Some("Alpha")
807        );
808        assert_eq!(
809            lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "B2"),
810            Some("Beta")
811        );
812        assert_eq!(
813            lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "XX"),
814            None
815        );
816        assert!(!lookup.is_code_field("sg4.sg8_test.sg10", "CCI", 0, 0));
817    }
818
819    #[test]
820    fn test_discriminated_variant_merge() {
821        // Schema with discriminated SG12 variants (sg12_z63, sg12_z65)
822        let schema = serde_json::json!({
823            "fields": {
824                "sg4": {
825                    "children": {
826                        "sg12_z63": {
827                            "segments": [{
828                                "id": "NAD",
829                                "elements": [{
830                                    "index": 0,
831                                    "type": "code",
832                                    "codes": [{"value": "Z63", "name": "Standortadresse"}]
833                                }]
834                            }],
835                            "source_group": "SG12"
836                        },
837                        "sg12_z65": {
838                            "segments": [{
839                                "id": "NAD",
840                                "elements": [
841                                    {
842                                        "index": 0,
843                                        "type": "code",
844                                        "codes": [{"value": "Z65", "name": "Kunde des LF"}]
845                                    },
846                                    {
847                                        "index": 3,
848                                        "components": [{
849                                            "sub_index": 5,
850                                            "type": "code",
851                                            "codes": [
852                                                {"value": "Z01", "name": "Herr"},
853                                                {"value": "Z02", "name": "Frau"}
854                                            ]
855                                        }]
856                                    }
857                                ]
858                            }],
859                            "source_group": "SG12"
860                        }
861                    },
862                    "segments": [],
863                    "source_group": "SG4"
864                }
865            }
866        });
867
868        let lookup = CodeLookup::from_schema_value(&schema);
869
870        // Variant-specific paths still work
871        assert!(lookup.is_code_field("sg4.sg12_z63", "NAD", 0, 0));
872        assert!(lookup.is_code_field("sg4.sg12_z65", "NAD", 0, 0));
873
874        // Base path also works (merged from variants)
875        assert!(lookup.is_code_field("sg4.sg12", "NAD", 0, 0));
876        assert_eq!(
877            lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z63"),
878            Some("Standortadresse")
879        );
880        assert_eq!(
881            lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z65"),
882            Some("Kunde des LF")
883        );
884
885        // Anrede code from z65 also available at base path
886        assert!(lookup.is_code_field("sg4.sg12", "NAD", 3, 5));
887        assert_eq!(
888            lookup.meaning_for("sg4.sg12", "NAD", 3, 5, "Z01"),
889            Some("Herr")
890        );
891    }
892
893    #[test]
894    fn test_pid_55013_sg12_base_path() {
895        let schema_path = Path::new(concat!(
896            env!("CARGO_MANIFEST_DIR"),
897            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55013_schema.json"
898        ));
899        if !schema_path.exists() {
900            eprintln!("Skipping: PID schema not found");
901            return;
902        }
903
904        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
905
906        // Base path "sg4.sg12" should have merged NAD qualifier codes from all variants
907        assert!(lookup.is_code_field("sg4.sg12", "NAD", 0, 0));
908        // Z67 meaning comes from sg12_z67 variant
909        assert!(lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z67").is_some());
910        // All 7 SG12 qualifiers should be present
911        for code in &["Z63", "Z65", "Z66", "Z67", "Z68", "Z69", "Z70"] {
912            assert!(
913                lookup.meaning_for("sg4.sg12", "NAD", 0, 0, code).is_some(),
914                "Missing meaning for NAD qualifier {code} at base path sg4.sg12"
915            );
916        }
917    }
918
919    #[test]
920    fn test_multi_segment_code_merge() {
921        // SG10 with 3 CCI segments at same element position but different codes.
922        // All codes should be merged, not overwritten by last CCI.
923        let schema = serde_json::json!({
924            "fields": {
925                "sg4": {
926                    "children": {
927                        "sg8_z98": {
928                            "children": {
929                                "sg10": {
930                                    "segments": [
931                                        {
932                                            "id": "CCI",
933                                            "elements": [{"index": 2, "components": [{
934                                                "sub_index": 0, "type": "code",
935                                                "codes": [{"value": "ZB3", "name": "Zugeordneter Marktpartner"}]
936                                            }]}]
937                                        },
938                                        {
939                                            "id": "CAV",
940                                            "elements": [{"index": 0, "components": [{
941                                                "sub_index": 0, "type": "code",
942                                                "codes": [{"value": "Z91", "name": "MSB"}]
943                                            }]}]
944                                        },
945                                        {
946                                            "id": "CCI",
947                                            "elements": [{"index": 2, "components": [{
948                                                "sub_index": 0, "type": "code",
949                                                "codes": [{"value": "E03", "name": "Spannungsebene"}]
950                                            }]}]
951                                        },
952                                        {
953                                            "id": "CAV",
954                                            "elements": [{"index": 0, "components": [{
955                                                "sub_index": 0, "type": "code",
956                                                "codes": [
957                                                    {"value": "E05", "name": "Mittelspannung"},
958                                                    {"value": "E06", "name": "Niederspannung"}
959                                                ]
960                                            }]}]
961                                        },
962                                        {
963                                            "id": "CCI",
964                                            "elements": [{"index": 2, "components": [{
965                                                "sub_index": 0, "type": "code",
966                                                "codes": [
967                                                    {"value": "Z15", "name": "Haushaltskunde"},
968                                                    {"value": "Z18", "name": "Kein Haushaltskunde"}
969                                                ]
970                                            }]}]
971                                        }
972                                    ],
973                                    "source_group": "SG10"
974                                }
975                            },
976                            "segments": [],
977                            "source_group": "SG8"
978                        }
979                    },
980                    "segments": [],
981                    "source_group": "SG4"
982                }
983            }
984        });
985
986        let lookup = CodeLookup::from_schema_value(&schema);
987
988        // All CCI codes at (2,0) should be present (merged, not overwritten)
989        assert_eq!(
990            lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "ZB3"),
991            Some("Zugeordneter Marktpartner")
992        );
993        assert_eq!(
994            lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "E03"),
995            Some("Spannungsebene")
996        );
997        assert_eq!(
998            lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "Z15"),
999            Some("Haushaltskunde")
1000        );
1001
1002        // All CAV codes at (0,0) should be present
1003        assert_eq!(
1004            lookup.meaning_for("sg4.sg8_z98.sg10", "CAV", 0, 0, "Z91"),
1005            Some("MSB")
1006        );
1007        assert_eq!(
1008            lookup.meaning_for("sg4.sg8_z98.sg10", "CAV", 0, 0, "E06"),
1009            Some("Niederspannung")
1010        );
1011    }
1012
1013    #[test]
1014    fn test_enrichment_for_with_enum() {
1015        let schema = serde_json::json!({
1016            "fields": {
1017                "sg4": {
1018                    "children": {
1019                        "sg10": {
1020                            "segments": [{
1021                                "id": "CCI",
1022                                "elements": [{
1023                                    "index": 2,
1024                                    "components": [{
1025                                        "sub_index": 0,
1026                                        "type": "code",
1027                                        "codes": [
1028                                            {"value": "Z15", "name": "Haushaltskunde", "enum": "HAUSHALTSKUNDE"},
1029                                            {"value": "Z18", "name": "Kein Haushaltskunde", "enum": "KEIN_HAUSHALTSKUNDE"}
1030                                        ]
1031                                    }]
1032                                }]
1033                            }],
1034                            "source_group": "SG10"
1035                        }
1036                    },
1037                    "segments": [],
1038                    "source_group": "SG4"
1039                }
1040            }
1041        });
1042
1043        let lookup = CodeLookup::from_schema_value(&schema);
1044
1045        let enrichment = lookup.enrichment_for("sg4.sg10", "CCI", 2, 0, "Z15");
1046        assert!(enrichment.is_some());
1047        let e = enrichment.unwrap();
1048        assert_eq!(e.meaning, "Haushaltskunde");
1049        assert_eq!(e.enum_key.as_deref(), Some("HAUSHALTSKUNDE"));
1050
1051        let e2 = lookup
1052            .enrichment_for("sg4.sg10", "CCI", 2, 0, "Z18")
1053            .unwrap();
1054        assert_eq!(e2.enum_key.as_deref(), Some("KEIN_HAUSHALTSKUNDE"));
1055
1056        // meaning_for still works
1057        assert_eq!(
1058            lookup.meaning_for("sg4.sg10", "CCI", 2, 0, "Z15"),
1059            Some("Haushaltskunde")
1060        );
1061    }
1062
1063    #[test]
1064    fn test_backward_compat_no_enum() {
1065        // Old schema format without "enum" field — should still work, enum_key is None
1066        let schema = serde_json::json!({
1067            "fields": {
1068                "sg4": {
1069                    "children": {
1070                        "sg10": {
1071                            "segments": [{
1072                                "id": "CCI",
1073                                "elements": [{
1074                                    "index": 2,
1075                                    "components": [{
1076                                        "sub_index": 0,
1077                                        "type": "code",
1078                                        "codes": [
1079                                            {"value": "Z15", "name": "Haushaltskunde"}
1080                                        ]
1081                                    }]
1082                                }]
1083                            }],
1084                            "source_group": "SG10"
1085                        }
1086                    },
1087                    "segments": [],
1088                    "source_group": "SG4"
1089                }
1090            }
1091        });
1092
1093        let lookup = CodeLookup::from_schema_value(&schema);
1094        let enrichment = lookup.enrichment_for("sg4.sg10", "CCI", 2, 0, "Z15");
1095        assert!(enrichment.is_some());
1096        let e = enrichment.unwrap();
1097        assert_eq!(e.meaning, "Haushaltskunde");
1098        assert_eq!(e.enum_key, None); // No enum in old schema
1099    }
1100
1101    /// SG15 of IFTSTA 21037: RFF+Z13 (d1154 = the PID, a code), RFF+ACW and
1102    /// RFF+ACE (d1154 = free reference, data); plus two CAV variants whose value
1103    /// component carries different code lists.
1104    fn qualified_variants_schema() -> Value {
1105        let rff = |qual: &str, name: &str, id_codes: Option<Value>| {
1106            let id = match id_codes {
1107                Some(codes) => {
1108                    serde_json::json!({"sub_index": 1, "id": "1154", "type": "code", "codes": codes})
1109                }
1110                None => serde_json::json!({"sub_index": 1, "id": "1154", "type": "data"}),
1111            };
1112            serde_json::json!({"id": "RFF", "elements": [{"index": 0, "composite": "C506", "components": [
1113                {"sub_index": 0, "id": "1153", "type": "code", "codes": [{"value": qual, "name": name}]},
1114                id,
1115            ]}]})
1116        };
1117        let cav = |qual: &str, codes: Value| {
1118            serde_json::json!({"id": "CAV", "elements": [{"index": 0, "composite": "C889", "components": [
1119                {"sub_index": 0, "id": "7111", "type": "code", "codes": [{"value": qual, "name": qual}]},
1120                {"sub_index": 1, "id": "7110", "type": "code", "codes": codes},
1121            ]}]})
1122        };
1123        serde_json::json!({"fields": {"sg14": {"segments": [], "children": {"sg15": {"segments": [
1124            rff("Z13", "Prüfidentifikator", Some(serde_json::json!([{"value": "21037", "name": "RD / NB-Bewertung"}]))),
1125            rff("ACW", "Referenznummer einer vorangegangenen Nachricht", None),
1126            rff("ACE", "Nummer des zugehörigen Dokuments", None),
1127            cav("Z91", serde_json::json!([{"value": "A", "name": "Alpha"}, {"value": "B", "name": "Beta"}])),
1128            cav("ZF0", serde_json::json!([{"value": "C", "name": "Gamma"}])),
1129        ]}}}}})
1130    }
1131
1132    #[test]
1133    fn qualified_lookup_uses_only_codes_of_that_segment_variant() {
1134        let lookup = CodeLookup::from_schema_value(&qualified_variants_schema());
1135        let sp = "sg14.sg15";
1136
1137        // RFF+ACW / RFF+ACE d1154 are data: no fallback to RFF+Z13's PID code.
1138        for qual in ["ACW", "ACE"] {
1139            assert!(
1140                lookup.codes_q(sp, "RFF", Some(qual), 0, 1).is_none(),
1141                "{qual}"
1142            );
1143            assert!(
1144                !lookup.is_code_field_q(sp, "RFF", Some(qual), 0, 1),
1145                "{qual}"
1146            );
1147            assert!(lookup
1148                .enrichment_for_q(sp, "RFF", Some(qual), 0, 1, "21037")
1149                .is_none());
1150        }
1151        let z13: Vec<&String> = lookup
1152            .codes_q(sp, "RFF", Some("Z13"), 0, 1)
1153            .unwrap()
1154            .keys()
1155            .collect();
1156        assert_eq!(z13, ["21037"]);
1157        let acw: Vec<&String> = lookup
1158            .codes_q(sp, "RFF", Some("ACW"), 0, 0)
1159            .unwrap()
1160            .keys()
1161            .collect();
1162        assert_eq!(acw, ["ACW"]);
1163
1164        // Tags without a qualifier convention (CAV) are variant-scoped too when a
1165        // qualifier is given; without one they keep the union of all variants.
1166        let z91: Vec<&String> = lookup
1167            .codes_q(sp, "CAV", Some("Z91"), 0, 1)
1168            .unwrap()
1169            .keys()
1170            .collect();
1171        assert_eq!(z91, ["A", "B"]);
1172        assert!(lookup
1173            .enrichment_for_q(sp, "CAV", Some("Z91"), 0, 1, "C")
1174            .is_none());
1175        let zf0: Vec<&String> = lookup
1176            .codes_q(sp, "CAV", Some("ZF0"), 0, 1)
1177            .unwrap()
1178            .keys()
1179            .collect();
1180        assert_eq!(zf0, ["C"]);
1181        let all: Vec<&String> = lookup
1182            .codes_q(sp, "CAV", None, 0, 1)
1183            .unwrap()
1184            .keys()
1185            .collect();
1186        assert_eq!(all, ["A", "B", "C"]);
1187        // A qualifier that names no variant of the tag (e.g. a discriminator on
1188        // another segment) still falls back to the unqualified entry.
1189        assert!(lookup.is_code_field_q(sp, "CAV", Some("Z98"), 0, 1));
1190    }
1191
1192    #[test]
1193    fn qualified_lookup_survives_cache_serialization() {
1194        let lookup = CodeLookup::from_schema_value(&qualified_variants_schema());
1195        let back: CodeLookup =
1196            serde_json::from_str(&serde_json::to_string(&lookup).unwrap()).unwrap();
1197        assert!(back
1198            .codes_q("sg14.sg15", "RFF", Some("ACW"), 0, 1)
1199            .is_none());
1200        let z91: Vec<&String> = back
1201            .codes_q("sg14.sg15", "CAV", Some("Z91"), 0, 1)
1202            .unwrap()
1203            .keys()
1204            .collect();
1205        assert_eq!(z91, ["A", "B"]);
1206    }
1207
1208    #[test]
1209    fn rff_tn_in_55002_is_not_a_code_field() {
1210        let schema_path = Path::new(concat!(
1211            env!("CARGO_MANIFEST_DIR"),
1212            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55002_schema.json"
1213        ));
1214        if !schema_path.exists() {
1215            return;
1216        }
1217        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
1218
1219        // RFF+TN component 1 is type=data (Vorgangsnummer), must NOT be a code field.
1220        assert!(
1221            !lookup.is_code_field_q("sg4.sg6", "RFF", Some("TN"), 0, 1),
1222            "RFF+TN d1154 is free-text Vorgangsnummer, must not be classified as code"
1223        );
1224
1225        // RFF+Z13 component 1 IS a code field with the PID value (Class C; suppression
1226        // happens elsewhere — here we just confirm the lookup classifies it as code).
1227        assert!(
1228            lookup.is_code_field_q("sg4.sg6", "RFF", Some("Z13"), 0, 1),
1229            "RFF+Z13 d1154 is type=code with PID-identifier value"
1230        );
1231    }
1232
1233    #[test]
1234    fn pid_self_reference_detection() {
1235        let schema_path = Path::new(concat!(
1236            env!("CARGO_MANIFEST_DIR"),
1237            "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55002_schema.json"
1238        ));
1239        if !schema_path.exists() {
1240            return;
1241        }
1242        let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
1243
1244        // RFF+Z13 d1154's only allowed value is "55002" — the PID itself.
1245        assert!(
1246            lookup.is_pid_self_reference("sg4.sg6", "RFF", Some("Z13"), 0, 1, "55002"),
1247            "Z13 d1154 with single value '55002' must be detected as PID self-ref"
1248        );
1249        // Same field for a different PID should NOT count as self-reference.
1250        assert!(
1251            !lookup.is_pid_self_reference("sg4.sg6", "RFF", Some("Z13"), 0, 1, "55001"),
1252            "Z13 d1154's '55002' should not count as self-ref for PID 55001"
1253        );
1254    }
1255}