mig-bo4e 0.14.0

Declarative TOML-based MIG-tree to BO4E mapping engine
Documentation
//! Shared code lists: the EDIFACT-code-to-name tables, held once instead of
//! once per rule.
//!
//! A rule that translates a code used to carry the whole table inline. Measured
//! across the mapping tree that was 12136 tables of which 388 were distinct --
//! 12.6 MB of text for 0.12 MB of content, duplicated again into each of the
//! 3566 compiled cache files the runtime image ships. The image went 34 MB over
//! its budget on one format version alone.
//!
//! So a rule names a list instead of repeating it:
//!
//! ```toml
//! "sts[E01].c556.d9013" = { target = "transaktionsgrund", code_list = "transaktionsgrund" }
//! ```
//!
//! and `mappings/code_lists.toml` holds each table once. An inline `enum_map`
//! still works and still wins where both are given, because a handful of rules
//! carry a table nothing else shares and a name for those would be noise.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};

/// The file a mappings tree keeps its shared tables in.
pub const CODE_LISTS_FILE: &str = "code_lists.toml";

/// Named EDIFACT-code-to-BO4E-name tables.
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct CodeLists {
    #[serde(flatten)]
    lists: BTreeMap<String, BTreeMap<String, String>>,
}

impl CodeLists {
    /// The table a structured mapping translates through: its own inline
    /// `enum_map`, else the shared list its `code_list` names. An inline table
    /// wins where both are given — the one rule every reader of a mapping
    /// (engine, requirements, output shape) must apply alike.
    pub fn resolve<'a>(
        &'a self,
        inline: Option<&'a BTreeMap<String, String>>,
        named: Option<&str>,
    ) -> Option<&'a BTreeMap<String, String>> {
        inline.or_else(|| named.and_then(|n| self.get(n)))
    }

    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())
    }

    /// Read the tables from `path`, or an empty set when the file is absent.
    ///
    /// Absent is not an error: a mappings tree that names no list needs no file,
    /// and every unit test builds definitions in a temporary directory.
    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())),
        }
    }

    /// Find and read the tables for a directory inside a mappings tree.
    ///
    /// Definitions are loaded from `mappings/<FV>/<variant>/<pid>/`, and every
    /// loader is handed one of those directories rather than the tree root, so
    /// the file is found by walking up. Results are cached per resolved path:
    /// a format version builds a couple of thousand engines, and each re-read
    /// would parse the same file again.
    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| {
            // A malformed shared table would otherwise turn every code into a
            // silent passthrough, which reads as "the guide lists no codes here"
            // rather than as the breakage it is.
            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() {
        // Unit tests build definitions in temporary directories that hold no
        // table file, and a tree that names no list needs none.
        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() {
        // `code_list` had to be added to the accepted-key list; a typo of it
        // must not become a silently ignored field (issue #96).
        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}");
    }
}