Skip to main content

mig_bo4e/
code_lists.rs

1//! Shared code lists: the EDIFACT-code-to-name tables, held once instead of
2//! once per rule.
3//!
4//! A rule that translates a code used to carry the whole table inline. Measured
5//! across the mapping tree that was 12136 tables of which 388 were distinct --
6//! 12.6 MB of text for 0.12 MB of content, duplicated again into each of the
7//! 3566 compiled cache files the runtime image ships. The image went 34 MB over
8//! its budget on one format version alone.
9//!
10//! So a rule names a list instead of repeating it:
11//!
12//! ```toml
13//! "sts[E01].c556.d9013" = { target = "transaktionsgrund", code_list = "transaktionsgrund" }
14//! ```
15//!
16//! and `mappings/code_lists.toml` holds each table once. An inline `enum_map`
17//! still works and still wins where both are given, because a handful of rules
18//! carry a table nothing else shares and a name for those would be noise.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, OnceLock, RwLock};
23
24/// The file a mappings tree keeps its shared tables in.
25pub const CODE_LISTS_FILE: &str = "code_lists.toml";
26
27/// Named EDIFACT-code-to-BO4E-name tables.
28#[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    /// The table a structured mapping translates through: its own inline
36    /// `enum_map`, else the shared list its `code_list` names. An inline table
37    /// wins where both are given — the one rule every reader of a mapping
38    /// (engine, requirements, output shape) must apply alike.
39    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    /// Read the tables from `path`, or an empty set when the file is absent.
68    ///
69    /// Absent is not an error: a mappings tree that names no list needs no file,
70    /// and every unit test builds definitions in a temporary directory.
71    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    /// Find and read the tables for a directory inside a mappings tree.
80    ///
81    /// Definitions are loaded from `mappings/<FV>/<variant>/<pid>/`, and every
82    /// loader is handed one of those directories rather than the tree root, so
83    /// the file is found by walking up. Results are cached per resolved path:
84    /// a format version builds a couple of thousand engines, and each re-read
85    /// would parse the same file again.
86    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            // A malformed shared table would otherwise turn every code into a
103            // silent passthrough, which reads as "the guide lists no codes here"
104            // rather than as the breakage it is.
105            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        // Unit tests build definitions in temporary directories that hold no
137        // table file, and a tree that names no list needs none.
138        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        // `code_list` had to be added to the accepted-key list; a typo of it
170        // must not become a silently ignored field (issue #96).
171        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}