use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
pub const CODE_LISTS_FILE: &str = "code_lists.toml";
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct CodeLists {
#[serde(flatten)]
lists: BTreeMap<String, BTreeMap<String, String>>,
}
impl CodeLists {
pub fn get(&self, name: &str) -> Option<&BTreeMap<String, String>> {
self.lists.get(name)
}
pub fn is_empty(&self) -> bool {
self.lists.is_empty()
}
pub fn len(&self) -> usize {
self.lists.len()
}
pub fn names(&self) -> impl Iterator<Item = &String> {
self.lists.keys()
}
pub fn from_toml_str(text: &str) -> Result<Self, String> {
toml::from_str(text).map_err(|e| e.to_string())
}
pub fn read(path: &Path) -> Result<Self, String> {
match std::fs::read_to_string(path) {
Ok(text) => Self::from_toml_str(&text).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
pub fn discover(start: &Path) -> Arc<Self> {
static CACHE: OnceLock<RwLock<BTreeMap<PathBuf, Arc<CodeLists>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| RwLock::new(BTreeMap::new()));
let found = start
.ancestors()
.map(|dir| dir.join(CODE_LISTS_FILE))
.find(|candidate| candidate.is_file());
let Some(path) = found else {
return Arc::new(Self::default());
};
if let Some(hit) = cache.read().ok().and_then(|c| c.get(&path).cloned()) {
return hit;
}
let lists = Arc::new(Self::read(&path).unwrap_or_else(|e| {
panic!("shared code lists are unreadable: {e}")
}));
if let Ok(mut c) = cache.write() {
c.insert(path, Arc::clone(&lists));
}
lists
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::definition::{FieldMapping, MappingDefinition};
const LISTS: &str = r#"
[transaktionsgrund]
"E01" = "kundeBleibt"
"E03" = "kundeZiehtAus"
"#;
#[test]
fn a_named_list_resolves_to_its_table() {
let lists = CodeLists::from_toml_str(LISTS).expect("parses");
assert_eq!(lists.len(), 1);
let table = lists.get("transaktionsgrund").expect("named list");
assert_eq!(table.get("E01").map(String::as_str), Some("kundeBleibt"));
assert!(lists.get("no-such-list").is_none());
}
#[test]
fn a_missing_file_is_an_empty_set_not_an_error() {
let lists = CodeLists::read(Path::new("/nonexistent/code_lists.toml")).expect("no error");
assert!(lists.is_empty());
}
#[test]
fn a_rule_can_name_a_list_instead_of_repeating_it() {
let def = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "Prozessdaten"
bo4e_type = "Prozessdaten"
source_group = "SG4"
[fields]
"sts.c556.d9013" = { target = "transaktionsgrund", code_list = "transaktionsgrund" }
"#,
)
.expect("parses");
let mapping = def.fields.get("sts.c556.d9013").expect("the field");
let FieldMapping::Structured(s) = mapping else {
panic!("expected a structured mapping, got {mapping:?}");
};
assert_eq!(s.code_list.as_deref(), Some("transaktionsgrund"));
assert!(
s.enum_map.is_none(),
"naming a list must not conjure an inline table"
);
}
#[test]
fn an_unknown_key_in_a_field_table_is_still_refused() {
let err = MappingDefinition::from_toml_str(
r#"
[meta]
entity = "Prozessdaten"
bo4e_type = "Prozessdaten"
source_group = "SG4"
[fields]
"sts.c556.d9013" = { target = "x", code_lists = "transaktionsgrund" }
"#,
)
.expect_err("a misspelled key must be refused");
assert!(err.contains("code_lists"), "{err}");
}
}