1use serde_json::Value;
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::path::Path;
9
10pub type CodeLookupKey = (String, String, Option<String>, usize, usize);
20
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct CodeEnrichment {
24 pub meaning: String,
25 pub enum_key: Option<String>,
26}
27
28pub type CodeMeanings = BTreeMap<String, CodeEnrichment>;
31
32#[derive(Debug, Clone, Default)]
41pub struct CodeLookup {
42 entries: BTreeMap<CodeLookupKey, CodeMeanings>,
43 variants: HashSet<(String, String, String)>,
48}
49
50impl 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 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 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 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::add_base_path_aggregates(&mut entries);
127 Self::from_entries(entries)
128 }
129
130 fn from_entries(entries: BTreeMap<CodeLookupKey, CodeMeanings>) -> Self {
131 let variants = entries
132 .keys()
133 .filter_map(|(path, tag, qual, _, _)| {
134 qual.as_ref()
135 .map(|q| (path.clone(), tag.clone(), q.clone()))
136 })
137 .collect();
138 Self { entries, variants }
139 }
140
141 pub fn enrichment_codes(
151 &self,
152 source_path: &str,
153 segment_tag: &str,
154 path_qualifier: Option<&str>,
155 disc_qualifier: Option<&str>,
156 element_index: usize,
157 component_index: usize,
158 ) -> Option<&CodeMeanings> {
159 let at = |q| self.resolve_q(source_path, segment_tag, q, element_index, component_index);
160 let path_variant =
161 path_qualifier.filter(|q| self.is_known_variant(source_path, segment_tag, q));
162 match (path_variant, disc_qualifier) {
163 (Some(p), Some(d)) if p != d => at(Some(p)),
164 (Some(p), None) => at(None).and(at(Some(p))),
165 _ => at(disc_qualifier),
166 }
167 }
168
169 pub fn field_codes(
174 &self,
175 source_path: &str,
176 segment_tag: &str,
177 path_qualifier: Option<&str>,
178 disc_qualifier: Option<&str>,
179 element_index: usize,
180 component_index: usize,
181 ) -> Option<CodeMeanings> {
182 match path_qualifier.or(disc_qualifier) {
183 Some(q) => self
184 .resolve_q(
185 source_path,
186 segment_tag,
187 Some(q),
188 element_index,
189 component_index,
190 )
191 .cloned(),
192 None => Some(self.codes_all_qualifiers(
193 source_path,
194 segment_tag,
195 element_index,
196 component_index,
197 ))
198 .filter(|c| !c.is_empty()),
199 }
200 }
201
202 pub fn is_known_variant(&self, source_path: &str, segment_tag: &str, qualifier: &str) -> bool {
205 self.variants.contains(&(
206 source_path.to_string(),
207 segment_tag.to_string(),
208 qualifier.to_string(),
209 ))
210 }
211
212 fn resolve_q(
218 &self,
219 source_path: &str,
220 segment_tag: &str,
221 qualifier: Option<&str>,
222 element_index: usize,
223 component_index: usize,
224 ) -> Option<&CodeMeanings> {
225 let key = |q: Option<&str>| {
226 (
227 source_path.to_string(),
228 segment_tag.to_string(),
229 q.map(String::from),
230 element_index,
231 component_index,
232 )
233 };
234 match qualifier {
235 Some(q) if self.is_known_variant(source_path, segment_tag, q) => {
236 self.entries.get(&key(Some(q)))
237 }
238 Some(q) => self
239 .entries
240 .get(&key(Some(q)))
241 .or_else(|| self.entries.get(&key(None))),
242 None => self.entries.get(&key(None)),
243 }
244 }
245
246 #[deprecated(
255 note = "use is_code_field_q with the discriminator qualifier; this shim scans across all qualifiers"
256 )]
257 pub fn is_code_field(
258 &self,
259 source_path: &str,
260 segment_tag: &str,
261 element_index: usize,
262 component_index: usize,
263 ) -> bool {
264 self.entries.iter().any(|((p, t, _q, e, c), _)| {
267 p == source_path && t == segment_tag && *e == element_index && *c == component_index
268 })
269 }
270
271 pub fn is_code_field_q(
280 &self,
281 source_path: &str,
282 segment_tag: &str,
283 qualifier: Option<&str>,
284 element_index: usize,
285 component_index: usize,
286 ) -> bool {
287 self.resolve_q(
288 source_path,
289 segment_tag,
290 qualifier,
291 element_index,
292 component_index,
293 )
294 .is_some()
295 }
296
297 pub fn codes_q(
303 &self,
304 source_path: &str,
305 segment_tag: &str,
306 qualifier: Option<&str>,
307 element_index: usize,
308 component_index: usize,
309 ) -> Option<&CodeMeanings> {
310 self.resolve_q(
311 source_path,
312 segment_tag,
313 qualifier,
314 element_index,
315 component_index,
316 )
317 }
318
319 pub fn codes_all_qualifiers(
323 &self,
324 source_path: &str,
325 segment_tag: &str,
326 element_index: usize,
327 component_index: usize,
328 ) -> CodeMeanings {
329 let mut slots: Vec<(&Option<String>, &CodeMeanings)> = self
331 .entries
332 .iter()
333 .filter(|((p, t, _, e, c), _)| {
334 p == source_path && t == segment_tag && *e == element_index && *c == component_index
335 })
336 .map(|((_, _, q, _, _), meanings)| (q, meanings))
337 .collect();
338 slots.sort_by(|a, b| a.0.cmp(b.0));
339 let mut merged = CodeMeanings::new();
340 for (_, meanings) in slots {
341 for (code, enrichment) in meanings {
342 merged
343 .entry(code.clone())
344 .or_insert_with(|| enrichment.clone());
345 }
346 }
347 merged
348 }
349
350 #[deprecated(
355 note = "use enrichment_for_q with the discriminator qualifier; this shim scans across all qualifiers"
356 )]
357 pub fn enrichment_for(
358 &self,
359 source_path: &str,
360 segment_tag: &str,
361 element_index: usize,
362 component_index: usize,
363 value: &str,
364 ) -> Option<&CodeEnrichment> {
365 let unqualified_key = (
367 source_path.to_string(),
368 segment_tag.to_string(),
369 None,
370 element_index,
371 component_index,
372 );
373 if let Some(e) = self
374 .entries
375 .get(&unqualified_key)
376 .and_then(|meanings| meanings.get(value))
377 {
378 return Some(e);
379 }
380 self.entries
381 .iter()
382 .filter(|((p, t, q, e, c), _)| {
383 p == source_path
384 && t == segment_tag
385 && q.is_some()
386 && *e == element_index
387 && *c == component_index
388 })
389 .find_map(|(_, meanings)| meanings.get(value))
390 }
391
392 pub fn enrichment_for_q(
395 &self,
396 source_path: &str,
397 segment_tag: &str,
398 qualifier: Option<&str>,
399 element_index: usize,
400 component_index: usize,
401 value: &str,
402 ) -> Option<&CodeEnrichment> {
403 self.resolve_q(
404 source_path,
405 segment_tag,
406 qualifier,
407 element_index,
408 component_index,
409 )
410 .and_then(|meanings| meanings.get(value))
411 }
412
413 #[deprecated(
420 note = "use enrichment_for_q with the discriminator qualifier; this shim scans across all qualifiers"
421 )]
422 pub fn meaning_for(
423 &self,
424 source_path: &str,
425 segment_tag: &str,
426 element_index: usize,
427 component_index: usize,
428 value: &str,
429 ) -> Option<&str> {
430 #[allow(deprecated)]
431 self.enrichment_for(
432 source_path,
433 segment_tag,
434 element_index,
435 component_index,
436 value,
437 )
438 .map(|e| e.meaning.as_str())
439 }
440
441 pub fn is_pid_self_reference(
447 &self,
448 source_path: &str,
449 segment_tag: &str,
450 qualifier: Option<&str>,
451 element_index: usize,
452 component_index: usize,
453 pid: &str,
454 ) -> bool {
455 let key = (
456 source_path.to_string(),
457 segment_tag.to_string(),
458 qualifier.map(String::from),
459 element_index,
460 component_index,
461 );
462 if let Some(meanings) = self.entries.get(&key) {
463 meanings.len() == 1 && meanings.contains_key(pid)
464 } else {
465 false
466 }
467 }
468
469 fn walk_group(
471 path_prefix: &str,
472 group: &Value,
473 entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
474 ) {
475 if let Some(segments) = group.get("segments").and_then(|s| s.as_array()) {
476 for segment in segments {
477 let seg_id = segment
478 .get("id")
479 .and_then(|v| v.as_str())
480 .unwrap_or("")
481 .to_uppercase();
482 Self::process_segment(path_prefix, &seg_id, segment, entries);
483 }
484 }
485 if let Some(children) = group.get("children").and_then(|c| c.as_object()) {
486 for (child_key, child_value) in children {
487 let child_path = format!("{}.{}", path_prefix, child_key);
488 Self::walk_group(&child_path, child_value, entries);
489 }
490 }
491 }
492
493 fn process_segment(
500 source_path: &str,
501 segment_tag: &str,
502 segment: &Value,
503 entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>,
504 ) {
505 let Some(elements) = segment.get("elements").and_then(|e| e.as_array()) else {
506 return;
507 };
508
509 let qualifier = Self::extract_qualifier(segment_tag, elements);
510 let own_variant = if qualifier.is_none() && !Self::has_qualifier_convention(segment_tag) {
513 Self::single_leading_code(elements)
514 } else {
515 None
516 };
517 let slots: Vec<Option<String>> = std::iter::once(qualifier)
518 .chain(own_variant.map(Some))
519 .collect();
520
521 for element in elements {
522 let element_index = element.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
523
524 if let Some("code") = element.get("type").and_then(|v| v.as_str()) {
526 if let Some(codes) = element.get("codes").and_then(|c| c.as_array()) {
527 let meanings = Self::extract_codes(codes);
528 if !meanings.is_empty() {
529 for slot in &slots {
530 let key = (
531 source_path.to_string(),
532 segment_tag.to_string(),
533 slot.clone(),
534 element_index,
535 0,
536 );
537 entries.entry(key).or_default().extend(meanings.clone());
538 }
539 }
540 }
541 }
542
543 if let Some(components) = element.get("components").and_then(|c| c.as_array()) {
545 for component in components {
546 if let Some("code") = component.get("type").and_then(|v| v.as_str()) {
547 let sub_index = component
548 .get("sub_index")
549 .and_then(|v| v.as_u64())
550 .unwrap_or(0) as usize;
551 if let Some(codes) = component.get("codes").and_then(|c| c.as_array()) {
552 let meanings = Self::extract_codes(codes);
553 if !meanings.is_empty() {
554 for slot in &slots {
555 let key = (
556 source_path.to_string(),
557 segment_tag.to_string(),
558 slot.clone(),
559 element_index,
560 sub_index,
561 );
562 entries.entry(key).or_default().extend(meanings.clone());
563 }
564 }
565 }
566 }
567 }
568 }
569 }
570 }
571
572 fn extract_qualifier(segment_tag: &str, elements: &[Value]) -> Option<String> {
587 if !Self::has_qualifier_convention(segment_tag) {
588 return None;
589 }
590 Self::single_leading_code(elements)
591 }
592
593 fn has_qualifier_convention(segment_tag: &str) -> bool {
595 matches!(segment_tag, "RFF" | "STS" | "CCI" | "DTM")
596 }
597
598 fn single_leading_code(elements: &[Value]) -> Option<String> {
601 let element0 = elements
603 .iter()
604 .find(|el| el.get("index").and_then(|v| v.as_u64()) == Some(0))
605 .or_else(|| elements.first())?;
606
607 let component0 = element0
609 .get("components")
610 .and_then(|c| c.as_array())
611 .and_then(|comps| {
612 comps
613 .iter()
614 .find(|c| c.get("sub_index").and_then(|v| v.as_u64()) == Some(0))
615 .or_else(|| comps.first())
616 });
617
618 let codes_node = if let Some(comp) = component0 {
619 if comp.get("type").and_then(|v| v.as_str()) == Some("code") {
621 comp.get("codes").and_then(|c| c.as_array())
622 } else {
623 None
624 }
625 } else if element0.get("type").and_then(|v| v.as_str()) == Some("code") {
626 element0.get("codes").and_then(|c| c.as_array())
628 } else {
629 None
630 };
631
632 let codes = codes_node?;
633 if codes.len() != 1 {
634 return None; }
636 codes[0]
637 .get("value")
638 .and_then(|v| v.as_str())
639 .map(|s| s.to_string())
640 }
641
642 fn add_base_path_aggregates(entries: &mut BTreeMap<CodeLookupKey, CodeMeanings>) {
654 let mut aggregates: BTreeMap<CodeLookupKey, CodeMeanings> = BTreeMap::new();
655 for ((path, tag, qual, elem, comp), meanings) in entries.iter() {
656 let segments: Vec<&str> = path.split('.').collect();
657 let variants: Vec<usize> = (0..segments.len())
658 .filter(|&i| segments[i].contains('_'))
659 .collect();
660 for mask in 1u32..(1 << variants.len()) {
661 let mut base = segments.clone();
662 for (bit, &i) in variants.iter().enumerate() {
663 if mask & (1 << bit) != 0 {
664 base[i] = segments[i].split('_').next().unwrap_or(segments[i]);
665 }
666 }
667 aggregates
668 .entry((base.join("."), tag.clone(), qual.clone(), *elem, *comp))
669 .or_default()
670 .extend(meanings.iter().map(|(k, v)| (k.clone(), v.clone())));
671 }
672 }
673 for (key, meanings) in aggregates {
674 entries.entry(key).or_default().extend(meanings);
675 }
676 }
677
678 fn extract_codes(codes: &[Value]) -> CodeMeanings {
680 let mut meanings = BTreeMap::new();
681 for code in codes {
682 if let (Some(value), Some(name)) = (
683 code.get("value").and_then(|v| v.as_str()),
684 code.get("name").and_then(|v| v.as_str()),
685 ) {
686 let enum_key = code
687 .get("enum")
688 .and_then(|v| v.as_str())
689 .map(|s| s.to_string());
690 meanings.insert(
691 value.to_string(),
692 CodeEnrichment {
693 meaning: name.to_string(),
694 enum_key,
695 },
696 );
697 }
698 }
699 meanings
700 }
701}
702
703#[cfg(test)]
704#[allow(deprecated)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn test_parse_pid_55001_schema() {
710 let schema_path = Path::new(concat!(
711 env!("CARGO_MANIFEST_DIR"),
712 "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55001_schema.json"
713 ));
714 if !schema_path.exists() {
715 eprintln!("Skipping: PID schema not found");
716 return;
717 }
718
719 let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
720
721 assert!(lookup.is_code_field("sg4.sg8_z01.sg10", "CCI", 2, 0));
723 assert_eq!(
724 lookup.meaning_for("sg4.sg8_z01.sg10", "CCI", 2, 0, "Z15"),
725 Some("Haushaltskunde gem. EnWG")
726 );
727 assert_eq!(
728 lookup.meaning_for("sg4.sg8_z01.sg10", "CCI", 2, 0, "Z18"),
729 Some("Kein Haushaltskunde gem. EnWG")
730 );
731
732 assert!(lookup.is_code_field("sg4.sg8_z79.sg10", "CCI", 0, 0));
734 assert_eq!(
735 lookup.meaning_for("sg4.sg8_z79.sg10", "CCI", 0, 0, "Z66"),
736 Some("Produkteigenschaft")
737 );
738
739 assert!(lookup.is_code_field("sg4.sg8_z79.sg10", "CAV", 0, 0));
741
742 assert!(!lookup.is_code_field("sg4.sg8_z79.sg10", "CAV", 0, 3));
744
745 assert!(!lookup.is_code_field("sg4.sg5_z16", "LOC", 1, 0));
747 }
748
749 #[test]
750 fn test_from_inline_schema() {
751 let schema = serde_json::json!({
752 "fields": {
753 "sg4": {
754 "children": {
755 "sg8_test": {
756 "children": {
757 "sg10": {
758 "segments": [{
759 "id": "CCI",
760 "elements": [{
761 "index": 2,
762 "components": [{
763 "sub_index": 0,
764 "type": "code",
765 "codes": [
766 {"value": "A1", "name": "Alpha"},
767 {"value": "B2", "name": "Beta"}
768 ]
769 }]
770 }]
771 }],
772 "source_group": "SG10"
773 }
774 },
775 "segments": [],
776 "source_group": "SG8"
777 }
778 },
779 "segments": [],
780 "source_group": "SG4"
781 }
782 }
783 });
784
785 let lookup = CodeLookup::from_schema_value(&schema);
786
787 assert!(lookup.is_code_field("sg4.sg8_test.sg10", "CCI", 2, 0));
788 assert_eq!(
789 lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "A1"),
790 Some("Alpha")
791 );
792 assert_eq!(
793 lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "B2"),
794 Some("Beta")
795 );
796 assert_eq!(
797 lookup.meaning_for("sg4.sg8_test.sg10", "CCI", 2, 0, "XX"),
798 None
799 );
800 assert!(!lookup.is_code_field("sg4.sg8_test.sg10", "CCI", 0, 0));
801 }
802
803 #[test]
804 fn test_discriminated_variant_merge() {
805 let schema = serde_json::json!({
807 "fields": {
808 "sg4": {
809 "children": {
810 "sg12_z63": {
811 "segments": [{
812 "id": "NAD",
813 "elements": [{
814 "index": 0,
815 "type": "code",
816 "codes": [{"value": "Z63", "name": "Standortadresse"}]
817 }]
818 }],
819 "source_group": "SG12"
820 },
821 "sg12_z65": {
822 "segments": [{
823 "id": "NAD",
824 "elements": [
825 {
826 "index": 0,
827 "type": "code",
828 "codes": [{"value": "Z65", "name": "Kunde des LF"}]
829 },
830 {
831 "index": 3,
832 "components": [{
833 "sub_index": 5,
834 "type": "code",
835 "codes": [
836 {"value": "Z01", "name": "Herr"},
837 {"value": "Z02", "name": "Frau"}
838 ]
839 }]
840 }
841 ]
842 }],
843 "source_group": "SG12"
844 }
845 },
846 "segments": [],
847 "source_group": "SG4"
848 }
849 }
850 });
851
852 let lookup = CodeLookup::from_schema_value(&schema);
853
854 assert!(lookup.is_code_field("sg4.sg12_z63", "NAD", 0, 0));
856 assert!(lookup.is_code_field("sg4.sg12_z65", "NAD", 0, 0));
857
858 assert!(lookup.is_code_field("sg4.sg12", "NAD", 0, 0));
860 assert_eq!(
861 lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z63"),
862 Some("Standortadresse")
863 );
864 assert_eq!(
865 lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z65"),
866 Some("Kunde des LF")
867 );
868
869 assert!(lookup.is_code_field("sg4.sg12", "NAD", 3, 5));
871 assert_eq!(
872 lookup.meaning_for("sg4.sg12", "NAD", 3, 5, "Z01"),
873 Some("Herr")
874 );
875 }
876
877 #[test]
883 fn base_paths_cover_top_level_variants_and_their_descendants() {
884 let nad = |q: &str| {
885 serde_json::json!({
886 "id": "NAD",
887 "elements": [{"index": 0, "type": "code", "codes": [{"value": q, "name": q}]}]
888 })
889 };
890 let schema = serde_json::json!({
891 "fields": {
892 "sg2_ms": {
893 "segments": [nad("MS")],
894 "children": {
895 "sg5_ic": {
896 "segments": [{
897 "id": "CTA",
898 "elements": [{"index": 0, "type": "code",
899 "codes": [{"value": "IC", "name": "Informationskontakt"}]}]
900 }]
901 }
902 }
903 },
904 "sg2_mr": { "segments": [nad("MR")] },
905 "sg50_z01": { "segments": [nad("Z01")] }
906 }
907 });
908 let lookup = CodeLookup::from_schema_value(&schema);
909
910 let codes = |path: &str, tag: &str| {
911 lookup
912 .field_codes(path, tag, None, None, 0, 0)
913 .map(|c| c.keys().cloned().collect::<Vec<_>>())
914 };
915 assert_eq!(codes("sg2", "NAD"), Some(vec!["MR".into(), "MS".into()]));
916 assert_eq!(codes("sg2.sg5_ic", "CTA"), Some(vec!["IC".into()]));
917 assert_eq!(codes("sg2.sg5", "CTA"), Some(vec!["IC".into()]));
918 assert_eq!(codes("sg50", "NAD"), Some(vec!["Z01".into()]));
920 assert_eq!(codes("sg2_ms", "NAD"), Some(vec!["MS".into()]));
922 }
923
924 #[test]
925 fn test_pid_55013_sg12_base_path() {
926 let schema_path = Path::new(concat!(
927 env!("CARGO_MANIFEST_DIR"),
928 "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55013_schema.json"
929 ));
930 if !schema_path.exists() {
931 eprintln!("Skipping: PID schema not found");
932 return;
933 }
934
935 let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
936
937 assert!(lookup.is_code_field("sg4.sg12", "NAD", 0, 0));
939 assert!(lookup.meaning_for("sg4.sg12", "NAD", 0, 0, "Z67").is_some());
941 for code in &["Z63", "Z65", "Z66", "Z67", "Z68", "Z69", "Z70"] {
943 assert!(
944 lookup.meaning_for("sg4.sg12", "NAD", 0, 0, code).is_some(),
945 "Missing meaning for NAD qualifier {code} at base path sg4.sg12"
946 );
947 }
948 }
949
950 #[test]
951 fn test_multi_segment_code_merge() {
952 let schema = serde_json::json!({
955 "fields": {
956 "sg4": {
957 "children": {
958 "sg8_z98": {
959 "children": {
960 "sg10": {
961 "segments": [
962 {
963 "id": "CCI",
964 "elements": [{"index": 2, "components": [{
965 "sub_index": 0, "type": "code",
966 "codes": [{"value": "ZB3", "name": "Zugeordneter Marktpartner"}]
967 }]}]
968 },
969 {
970 "id": "CAV",
971 "elements": [{"index": 0, "components": [{
972 "sub_index": 0, "type": "code",
973 "codes": [{"value": "Z91", "name": "MSB"}]
974 }]}]
975 },
976 {
977 "id": "CCI",
978 "elements": [{"index": 2, "components": [{
979 "sub_index": 0, "type": "code",
980 "codes": [{"value": "E03", "name": "Spannungsebene"}]
981 }]}]
982 },
983 {
984 "id": "CAV",
985 "elements": [{"index": 0, "components": [{
986 "sub_index": 0, "type": "code",
987 "codes": [
988 {"value": "E05", "name": "Mittelspannung"},
989 {"value": "E06", "name": "Niederspannung"}
990 ]
991 }]}]
992 },
993 {
994 "id": "CCI",
995 "elements": [{"index": 2, "components": [{
996 "sub_index": 0, "type": "code",
997 "codes": [
998 {"value": "Z15", "name": "Haushaltskunde"},
999 {"value": "Z18", "name": "Kein Haushaltskunde"}
1000 ]
1001 }]}]
1002 }
1003 ],
1004 "source_group": "SG10"
1005 }
1006 },
1007 "segments": [],
1008 "source_group": "SG8"
1009 }
1010 },
1011 "segments": [],
1012 "source_group": "SG4"
1013 }
1014 }
1015 });
1016
1017 let lookup = CodeLookup::from_schema_value(&schema);
1018
1019 assert_eq!(
1021 lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "ZB3"),
1022 Some("Zugeordneter Marktpartner")
1023 );
1024 assert_eq!(
1025 lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "E03"),
1026 Some("Spannungsebene")
1027 );
1028 assert_eq!(
1029 lookup.meaning_for("sg4.sg8_z98.sg10", "CCI", 2, 0, "Z15"),
1030 Some("Haushaltskunde")
1031 );
1032
1033 assert_eq!(
1035 lookup.meaning_for("sg4.sg8_z98.sg10", "CAV", 0, 0, "Z91"),
1036 Some("MSB")
1037 );
1038 assert_eq!(
1039 lookup.meaning_for("sg4.sg8_z98.sg10", "CAV", 0, 0, "E06"),
1040 Some("Niederspannung")
1041 );
1042 }
1043
1044 #[test]
1045 fn test_enrichment_for_with_enum() {
1046 let schema = serde_json::json!({
1047 "fields": {
1048 "sg4": {
1049 "children": {
1050 "sg10": {
1051 "segments": [{
1052 "id": "CCI",
1053 "elements": [{
1054 "index": 2,
1055 "components": [{
1056 "sub_index": 0,
1057 "type": "code",
1058 "codes": [
1059 {"value": "Z15", "name": "Haushaltskunde", "enum": "HAUSHALTSKUNDE"},
1060 {"value": "Z18", "name": "Kein Haushaltskunde", "enum": "KEIN_HAUSHALTSKUNDE"}
1061 ]
1062 }]
1063 }]
1064 }],
1065 "source_group": "SG10"
1066 }
1067 },
1068 "segments": [],
1069 "source_group": "SG4"
1070 }
1071 }
1072 });
1073
1074 let lookup = CodeLookup::from_schema_value(&schema);
1075
1076 let enrichment = lookup.enrichment_for("sg4.sg10", "CCI", 2, 0, "Z15");
1077 assert!(enrichment.is_some());
1078 let e = enrichment.unwrap();
1079 assert_eq!(e.meaning, "Haushaltskunde");
1080 assert_eq!(e.enum_key.as_deref(), Some("HAUSHALTSKUNDE"));
1081
1082 let e2 = lookup
1083 .enrichment_for("sg4.sg10", "CCI", 2, 0, "Z18")
1084 .unwrap();
1085 assert_eq!(e2.enum_key.as_deref(), Some("KEIN_HAUSHALTSKUNDE"));
1086
1087 assert_eq!(
1089 lookup.meaning_for("sg4.sg10", "CCI", 2, 0, "Z15"),
1090 Some("Haushaltskunde")
1091 );
1092 }
1093
1094 #[test]
1095 fn test_backward_compat_no_enum() {
1096 let schema = serde_json::json!({
1098 "fields": {
1099 "sg4": {
1100 "children": {
1101 "sg10": {
1102 "segments": [{
1103 "id": "CCI",
1104 "elements": [{
1105 "index": 2,
1106 "components": [{
1107 "sub_index": 0,
1108 "type": "code",
1109 "codes": [
1110 {"value": "Z15", "name": "Haushaltskunde"}
1111 ]
1112 }]
1113 }]
1114 }],
1115 "source_group": "SG10"
1116 }
1117 },
1118 "segments": [],
1119 "source_group": "SG4"
1120 }
1121 }
1122 });
1123
1124 let lookup = CodeLookup::from_schema_value(&schema);
1125 let enrichment = lookup.enrichment_for("sg4.sg10", "CCI", 2, 0, "Z15");
1126 assert!(enrichment.is_some());
1127 let e = enrichment.unwrap();
1128 assert_eq!(e.meaning, "Haushaltskunde");
1129 assert_eq!(e.enum_key, None); }
1131
1132 fn qualified_variants_schema() -> Value {
1136 let rff = |qual: &str, name: &str, id_codes: Option<Value>| {
1137 let id = match id_codes {
1138 Some(codes) => {
1139 serde_json::json!({"sub_index": 1, "id": "1154", "type": "code", "codes": codes})
1140 }
1141 None => serde_json::json!({"sub_index": 1, "id": "1154", "type": "data"}),
1142 };
1143 serde_json::json!({"id": "RFF", "elements": [{"index": 0, "composite": "C506", "components": [
1144 {"sub_index": 0, "id": "1153", "type": "code", "codes": [{"value": qual, "name": name}]},
1145 id,
1146 ]}]})
1147 };
1148 let cav = |qual: &str, codes: Value| {
1149 serde_json::json!({"id": "CAV", "elements": [{"index": 0, "composite": "C889", "components": [
1150 {"sub_index": 0, "id": "7111", "type": "code", "codes": [{"value": qual, "name": qual}]},
1151 {"sub_index": 1, "id": "7110", "type": "code", "codes": codes},
1152 ]}]})
1153 };
1154 serde_json::json!({"fields": {"sg14": {"segments": [], "children": {"sg15": {"segments": [
1155 rff("Z13", "Prüfidentifikator", Some(serde_json::json!([{"value": "21037", "name": "RD / NB-Bewertung"}]))),
1156 rff("ACW", "Referenznummer einer vorangegangenen Nachricht", None),
1157 rff("ACE", "Nummer des zugehörigen Dokuments", None),
1158 cav("Z91", serde_json::json!([{"value": "A", "name": "Alpha"}, {"value": "B", "name": "Beta"}])),
1159 cav("ZF0", serde_json::json!([{"value": "C", "name": "Gamma"}])),
1160 ]}}}}})
1161 }
1162
1163 #[test]
1164 fn qualified_lookup_uses_only_codes_of_that_segment_variant() {
1165 let lookup = CodeLookup::from_schema_value(&qualified_variants_schema());
1166 let sp = "sg14.sg15";
1167
1168 for qual in ["ACW", "ACE"] {
1170 assert!(
1171 lookup.codes_q(sp, "RFF", Some(qual), 0, 1).is_none(),
1172 "{qual}"
1173 );
1174 assert!(
1175 !lookup.is_code_field_q(sp, "RFF", Some(qual), 0, 1),
1176 "{qual}"
1177 );
1178 assert!(lookup
1179 .enrichment_for_q(sp, "RFF", Some(qual), 0, 1, "21037")
1180 .is_none());
1181 }
1182 let z13: Vec<&String> = lookup
1183 .codes_q(sp, "RFF", Some("Z13"), 0, 1)
1184 .unwrap()
1185 .keys()
1186 .collect();
1187 assert_eq!(z13, ["21037"]);
1188 let acw: Vec<&String> = lookup
1189 .codes_q(sp, "RFF", Some("ACW"), 0, 0)
1190 .unwrap()
1191 .keys()
1192 .collect();
1193 assert_eq!(acw, ["ACW"]);
1194
1195 let z91: Vec<&String> = lookup
1198 .codes_q(sp, "CAV", Some("Z91"), 0, 1)
1199 .unwrap()
1200 .keys()
1201 .collect();
1202 assert_eq!(z91, ["A", "B"]);
1203 assert!(lookup
1204 .enrichment_for_q(sp, "CAV", Some("Z91"), 0, 1, "C")
1205 .is_none());
1206 let zf0: Vec<&String> = lookup
1207 .codes_q(sp, "CAV", Some("ZF0"), 0, 1)
1208 .unwrap()
1209 .keys()
1210 .collect();
1211 assert_eq!(zf0, ["C"]);
1212 let all: Vec<&String> = lookup
1213 .codes_q(sp, "CAV", None, 0, 1)
1214 .unwrap()
1215 .keys()
1216 .collect();
1217 assert_eq!(all, ["A", "B", "C"]);
1218 assert!(lookup.is_code_field_q(sp, "CAV", Some("Z98"), 0, 1));
1221 }
1222
1223 #[test]
1224 fn qualified_lookup_survives_cache_serialization() {
1225 let lookup = CodeLookup::from_schema_value(&qualified_variants_schema());
1226 let back: CodeLookup =
1227 serde_json::from_str(&serde_json::to_string(&lookup).unwrap()).unwrap();
1228 assert!(back
1229 .codes_q("sg14.sg15", "RFF", Some("ACW"), 0, 1)
1230 .is_none());
1231 let z91: Vec<&String> = back
1232 .codes_q("sg14.sg15", "CAV", Some("Z91"), 0, 1)
1233 .unwrap()
1234 .keys()
1235 .collect();
1236 assert_eq!(z91, ["A", "B"]);
1237 }
1238
1239 #[test]
1240 fn rff_tn_in_55002_is_not_a_code_field() {
1241 let schema_path = Path::new(concat!(
1242 env!("CARGO_MANIFEST_DIR"),
1243 "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55002_schema.json"
1244 ));
1245 if !schema_path.exists() {
1246 return;
1247 }
1248 let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
1249
1250 assert!(
1252 !lookup.is_code_field_q("sg4.sg6", "RFF", Some("TN"), 0, 1),
1253 "RFF+TN d1154 is free-text Vorgangsnummer, must not be classified as code"
1254 );
1255
1256 assert!(
1259 lookup.is_code_field_q("sg4.sg6", "RFF", Some("Z13"), 0, 1),
1260 "RFF+Z13 d1154 is type=code with PID-identifier value"
1261 );
1262 }
1263
1264 #[test]
1265 fn pid_self_reference_detection() {
1266 let schema_path = Path::new(concat!(
1267 env!("CARGO_MANIFEST_DIR"),
1268 "/../../crates/mig-types/src/generated/fv2504/utilmd/pids/pid_55002_schema.json"
1269 ));
1270 if !schema_path.exists() {
1271 return;
1272 }
1273 let lookup = CodeLookup::from_schema_file(schema_path).unwrap();
1274
1275 assert!(
1277 lookup.is_pid_self_reference("sg4.sg6", "RFF", Some("Z13"), 0, 1, "55002"),
1278 "Z13 d1154 with single value '55002' must be detected as PID self-ref"
1279 );
1280 assert!(
1282 !lookup.is_pid_self_reference("sg4.sg6", "RFF", Some("Z13"), 0, 1, "55001"),
1283 "Z13 d1154's '55002' should not count as self-ref for PID 55001"
1284 );
1285 }
1286}