mig-bo4e 0.9.0

Declarative TOML-based MIG-tree to BO4E mapping engine
Documentation
//! Gate: an `enum_map` must be invertible, or its reverse silently mis-maps.
//!
//! The forward pass turns an EDIFACT code into a BO4E value; the reverse looks
//! the value back up. When several codes share one BO4E value the reverse
//! cannot tell them apart and picks the first key, so every other code comes
//! back as that one — a silent substitution, not an error.
//!
//! A map may instead be *jointly* injective: `also_target` splits one code
//! across two BO4E fields, and the pair identifies it. That is the mechanism
//! this gate accepts.
//!
//! Known collisions live in `tests/data/known_enum_map_collisions.txt`. The gate
//! fails when a collision appears that is not listed, and when a listed one has
//! been fixed, so the list cannot rot.
//!
//! A rule may hold its table inline or NAME a shared one in
//! `mappings/code_lists.toml` (`code_list` / `also_code_list`). Both are judged
//! here, resolved to the same table. Reading only the inline form is how this
//! gate went blind to 5306 rules the day they were migrated to names -- the
//! baseline did not move, because the rules had left the gate's view entirely.
//!
//! Three properties, in this order:
//!  1. every name resolves, and every table is used -- a typo must fail here
//!     rather than read as "this rule translates nothing";
//!  2. the table is injective, or jointly injective with its companion;
//!  3. where codes collide, the companion has an entry for every colliding
//!     code. Without that a code the companion omits falls through to the
//!     single-table lookup and comes back as the first key.
//!
//! Regenerate: `UPDATE_ENUM_COLLISIONS=1 cargo test -p mig-bo4e --test enum_map_injectivity_gate`

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

/// The shared tables a `code_list` name resolves against.
fn shared_lists() -> BTreeMap<String, BTreeMap<String, String>> {
    let path = mappings_root().join("code_lists.toml");
    let Ok(text) = std::fs::read_to_string(&path) else {
        return BTreeMap::new();
    };
    toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}

fn mappings_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../mappings")
}

fn baseline_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/known_enum_map_collisions.txt")
}

/// `<path relative to mappings/> <edifact field> <bo4e target>`
fn collisions() -> BTreeSet<String> {
    collisions_and_used().0
}

/// The collisions, and the names any rule referenced — so an unused table can
/// be reported too.
fn collisions_and_used() -> (BTreeSet<String>, BTreeSet<String>) {
    let root = mappings_root();
    let lists = shared_lists();
    let mut used: BTreeSet<String> = BTreeSet::new();
    let mut found = BTreeSet::new();

    for entry in walkdir(&root) {
        if entry.extension().and_then(|e| e.to_str()) != Some("toml") {
            continue;
        }
        let Ok(text) = std::fs::read_to_string(&entry) else {
            continue;
        };
        let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
            continue;
        };
        let Some(fields) = doc.get("fields").and_then(|f| f.as_table()) else {
            continue;
        };

        for (field_path, mapping) in fields {
            let Some(m) = mapping.as_table() else {
                continue;
            };
            let rel_for_err = entry
                .strip_prefix(&root)
                .unwrap_or(&entry)
                .display()
                .to_string();
            let resolve = |inline: &str, named: &str| -> Option<BTreeMap<String, String>> {
                if let Some(t) = m.get(inline).and_then(|e| e.as_table()) {
                    return Some(
                        t.iter()
                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                            .collect(),
                    );
                }
                let name = m.get(named).and_then(|e| e.as_str())?;
                Some(lists.get(name).cloned().unwrap_or_else(|| {
                    panic!(
                        "{rel_for_err} {field_path}: `{named} = \"{name}\"` names no table in \
                         mappings/code_lists.toml — a name that resolves to nothing reads as \
                         \"this rule translates nothing\" and the code reaches the output raw"
                    )
                }))
            };
            if let Some(n) = m.get("code_list").and_then(|e| e.as_str()) {
                used.insert(n.to_string());
            }
            if let Some(n) = m.get("also_code_list").and_then(|e| e.as_str()) {
                used.insert(n.to_string());
            }

            let Some(enum_map) = resolve("enum_map", "code_list") else {
                continue;
            };

            let values: Vec<&str> = enum_map.values().map(String::as_str).collect();
            if values.iter().collect::<BTreeSet<_>>().len() == values.len() {
                continue; // injective on its own
            }

            // Jointly injective? the companion keys the same codes.
            if let Some(also) = resolve("also_enum_map", "also_code_list") {
                let colliding: Vec<&String> = {
                    let mut seen = BTreeMap::new();
                    for (c, v) in &enum_map {
                        seen.entry(v.clone()).or_insert_with(Vec::new).push(c);
                    }
                    seen.into_values()
                        .filter(|g| g.len() > 1)
                        .flatten()
                        .collect()
                };
                // every colliding code needs a companion entry, or it falls
                // through to first-key-wins with no signal
                let covered = colliding.iter().all(|c| also.contains_key(*c));
                let joint: Vec<(&str, Option<&str>)> = enum_map
                    .iter()
                    .map(|(code, v)| (v.as_str(), also.get(code).map(String::as_str)))
                    .collect();
                if covered && joint.iter().collect::<BTreeSet<_>>().len() == joint.len() {
                    continue;
                }
            }

            let target = m.get("target").and_then(|t| t.as_str()).unwrap_or("?");
            found.insert(format!("{rel_for_err} {field_path} {target}"));
        }
    }
    (found, used)
}

fn walkdir(dir: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![dir.to_path_buf()];
    while let Some(d) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&d) else {
            continue;
        };
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                stack.push(p);
            } else {
                out.push(p);
            }
        }
    }
    out
}

#[test]
fn every_enum_map_is_invertible_or_known() {
    let found = collisions();

    if std::env::var("UPDATE_ENUM_COLLISIONS").is_ok() {
        let body: String = found
            .iter()
            .map(|l| format!("{l}\n"))
            .collect::<Vec<_>>()
            .concat();
        std::fs::write(baseline_path(), body).expect("write baseline");
        eprintln!("wrote {} known collisions", found.len());
        return;
    }

    let baseline: BTreeSet<String> = std::fs::read_to_string(baseline_path())
        .unwrap_or_default()
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(str::to_owned)
        .collect();

    assert!(
        !baseline.is_empty() || found.is_empty(),
        "baseline is missing or empty; regenerate with UPDATE_ENUM_COLLISIONS=1"
    );

    let new: Vec<&String> = found.difference(&baseline).collect();
    let fixed: Vec<&String> = baseline.difference(&found).collect();

    assert!(
        new.is_empty(),
        "{} enum_map(s) lost invertibility — several EDIFACT codes collapse to one \
         BO4E value with no `also_target` to tell them apart, so the reverse will \
         substitute the first code for all of them:\n{}",
        new.len(),
        new.iter()
            .map(|l| format!("  {l}"))
            .collect::<Vec<_>>()
            .join("\n")
    );
    assert!(
        fixed.is_empty(),
        "{} listed collision(s) are fixed — drop them from the baseline with \
         UPDATE_ENUM_COLLISIONS=1 so the list cannot rot:\n{}",
        fixed.len(),
        fixed
            .iter()
            .map(|l| format!("  {l}"))
            .collect::<Vec<_>>()
            .join("\n")
    );

    // A gate whose baseline covers everything proves nothing about new work; it
    // is the *boundary* that matters, so record the size the change must shrink.
    eprintln!("known enum_map collisions: {}", baseline.len());
}

/// Reference integrity, checked before invertibility: a name that resolves to
/// nothing reads exactly like a rule that translates nothing, so it has to
/// fail loudly rather than quietly widen the "no collisions" claim.
#[test]
fn every_named_code_list_resolves_and_is_used() {
    let lists = shared_lists();
    let (_, used) = collisions_and_used();

    // `collisions_and_used` panics on an unresolvable name, so reaching here
    // means every reference resolved. What remains is the other direction.
    let defined: BTreeSet<String> = lists.keys().cloned().collect();
    let unused: Vec<&String> = defined.difference(&used).collect();

    assert!(
        lists.is_empty() || !used.is_empty(),
        "mappings/code_lists.toml defines {} table(s) but no rule names one — \
         either the file is dead or the gate stopped seeing the references",
        lists.len()
    );
    assert!(
        unused.is_empty(),
        "{} shared table(s) are defined but referenced by no rule, so nothing \
         keeps them honest: {:?}",
        unused.len(),
        unused
    );
}