mig-bo4e 0.4.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.
//!
//! Regenerate: `UPDATE_ENUM_COLLISIONS=1 cargo test -p mig-bo4e --test enum_map_injectivity_gate`

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

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> {
    let root = mappings_root();
    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 Some(enum_map) = m.get("enum_map").and_then(|e| e.as_table()) else {
                continue;
            };

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

            // Jointly injective? `also_enum_map` keys the same codes.
            if let Some(also) = m.get("also_enum_map").and_then(|e| e.as_table()) {
                let joint: Vec<(Option<&str>, Option<&str>)> = enum_map
                    .iter()
                    .map(|(code, v)| (v.as_str(), also.get(code).and_then(|a| a.as_str())))
                    .collect();
                if joint.iter().collect::<BTreeSet<_>>().len() == joint.len() {
                    continue;
                }
            }

            let rel = entry.strip_prefix(&root).unwrap_or(&entry).display();
            let target = m.get("target").and_then(|t| t.as_str()).unwrap_or("?");
            found.insert(format!("{rel} {field_path} {target}"));
        }
    }
    found
}

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