1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, OnceLock, RwLock};
23
24pub const CODE_LISTS_FILE: &str = "code_lists.toml";
26
27#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
29pub struct CodeLists {
30 #[serde(flatten)]
31 lists: BTreeMap<String, BTreeMap<String, String>>,
32}
33
34impl CodeLists {
35 pub fn resolve<'a>(
40 &'a self,
41 inline: Option<&'a BTreeMap<String, String>>,
42 named: Option<&str>,
43 ) -> Option<&'a BTreeMap<String, String>> {
44 inline.or_else(|| named.and_then(|n| self.get(n)))
45 }
46
47 pub fn get(&self, name: &str) -> Option<&BTreeMap<String, String>> {
48 self.lists.get(name)
49 }
50
51 pub fn is_empty(&self) -> bool {
52 self.lists.is_empty()
53 }
54
55 pub fn len(&self) -> usize {
56 self.lists.len()
57 }
58
59 pub fn names(&self) -> impl Iterator<Item = &String> {
60 self.lists.keys()
61 }
62
63 pub fn from_toml_str(text: &str) -> Result<Self, String> {
64 toml::from_str(text).map_err(|e| e.to_string())
65 }
66
67 pub fn read(path: &Path) -> Result<Self, String> {
72 match std::fs::read_to_string(path) {
73 Ok(text) => Self::from_toml_str(&text).map_err(|e| format!("{}: {e}", path.display())),
74 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
75 Err(e) => Err(format!("{}: {e}", path.display())),
76 }
77 }
78
79 pub fn discover(start: &Path) -> Arc<Self> {
87 static CACHE: OnceLock<RwLock<BTreeMap<PathBuf, Arc<CodeLists>>>> = OnceLock::new();
88 let cache = CACHE.get_or_init(|| RwLock::new(BTreeMap::new()));
89
90 let found = start
91 .ancestors()
92 .map(|dir| dir.join(CODE_LISTS_FILE))
93 .find(|candidate| candidate.is_file());
94
95 let Some(path) = found else {
96 return Arc::new(Self::default());
97 };
98 if let Some(hit) = cache.read().ok().and_then(|c| c.get(&path).cloned()) {
99 return hit;
100 }
101 let lists = Arc::new(Self::read(&path).unwrap_or_else(|e| {
102 panic!("shared code lists are unreadable: {e}")
106 }));
107 if let Ok(mut c) = cache.write() {
108 c.insert(path, Arc::clone(&lists));
109 }
110 lists
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::definition::{FieldMapping, MappingDefinition};
118
119 const LISTS: &str = r#"
120[transaktionsgrund]
121"E01" = "kundeBleibt"
122"E03" = "kundeZiehtAus"
123"#;
124
125 #[test]
126 fn a_named_list_resolves_to_its_table() {
127 let lists = CodeLists::from_toml_str(LISTS).expect("parses");
128 assert_eq!(lists.len(), 1);
129 let table = lists.get("transaktionsgrund").expect("named list");
130 assert_eq!(table.get("E01").map(String::as_str), Some("kundeBleibt"));
131 assert!(lists.get("no-such-list").is_none());
132 }
133
134 #[test]
135 fn a_missing_file_is_an_empty_set_not_an_error() {
136 let lists = CodeLists::read(Path::new("/nonexistent/code_lists.toml")).expect("no error");
139 assert!(lists.is_empty());
140 }
141
142 #[test]
143 fn a_rule_can_name_a_list_instead_of_repeating_it() {
144 let def = MappingDefinition::from_toml_str(
145 r#"
146[meta]
147entity = "Prozessdaten"
148bo4e_type = "Prozessdaten"
149source_group = "SG4"
150
151[fields]
152"sts.c556.d9013" = { target = "transaktionsgrund", code_list = "transaktionsgrund" }
153"#,
154 )
155 .expect("parses");
156 let mapping = def.fields.get("sts.c556.d9013").expect("the field");
157 let FieldMapping::Structured(s) = mapping else {
158 panic!("expected a structured mapping, got {mapping:?}");
159 };
160 assert_eq!(s.code_list.as_deref(), Some("transaktionsgrund"));
161 assert!(
162 s.enum_map.is_none(),
163 "naming a list must not conjure an inline table"
164 );
165 }
166
167 #[test]
168 fn an_unknown_key_in_a_field_table_is_still_refused() {
169 let err = MappingDefinition::from_toml_str(
172 r#"
173[meta]
174entity = "Prozessdaten"
175bo4e_type = "Prozessdaten"
176source_group = "SG4"
177
178[fields]
179"sts.c556.d9013" = { target = "x", code_lists = "transaktionsgrund" }
180"#,
181 )
182 .expect_err("a misspelled key must be refused");
183 assert!(err.contains("code_lists"), "{err}");
184 }
185}