edifact-mapper 0.10.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! `to_edifact` takes a code field as its name or as its raw wire code, and
//! renders both the same.
//!
//! `from_edifact` writes a code through the rule's table (`enum_map`, or a
//! shared `code_list`): `Z65` in `NAD` D_3035 comes out as `kundeDesLf`. The
//! name is the canonical dialect, and the reverse mapping inverts the table to
//! write the code back. A value that is not a name in the table is written as
//! it is — so a caller holding raw codes (`"Z65"`), from an older release or
//! its own registry, gets the same message as one holding names. No table has
//! a code that is also one of its names, so the two readings cannot collide.
//!
//! This test pins that contract: every name a message's forward output holds
//! is swapped for its code, and the rendered message must not change.

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

use edifact_mapper::{DataDir, Mapper};
use mig_bo4e::code_lists::CodeLists;
use mig_bo4e::definition::{FieldMapping, MappingDefinition};
use mig_bo4e::model::Nachricht;
use serde_json::Value;

const FV: &str = "FV2604";

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .expect("the crate sits two levels below the workspace root")
        .to_path_buf()
}

fn toml_files(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for e in entries.flatten() {
        let p = e.path();
        if p.is_dir() {
            toml_files(&p, out);
        } else if p.extension().is_some_and(|x| x == "toml") {
            out.push(p);
        }
    }
}

/// Name → code for every JSON key a table-translated rule of the variant
/// writes (the target's last segment), keeping only names that stand for one
/// code under that key in every table — the swap must be exact.
fn names_to_codes(root: &Path, variant: &str) -> BTreeMap<String, BTreeMap<String, String>> {
    let lists = CodeLists::read(&root.join("mappings/code_lists.toml")).expect("code lists");
    let mut files = Vec::new();
    toml_files(&root.join("mappings").join(FV).join(variant), &mut files);
    let mut seen: BTreeMap<String, BTreeMap<String, BTreeSet<String>>> = BTreeMap::new();
    for f in files {
        let text = std::fs::read_to_string(&f).expect("read mapping");
        let def = MappingDefinition::from_toml_str(&text).expect("parse mapping");
        for mapping in def.fields.values() {
            let FieldMapping::Structured(s) = mapping else {
                continue;
            };
            let table = match (&s.enum_map, &s.code_list) {
                (Some(m), _) => m,
                (None, Some(name)) => match lists.get(name) {
                    Some(m) => m,
                    None => continue,
                },
                (None, None) => continue,
            };
            let Some(key) = s.target.rsplit('.').next().filter(|k| !k.is_empty()) else {
                continue;
            };
            let by_name = seen.entry(key.to_string()).or_default();
            for (code, name) in table {
                by_name
                    .entry(name.clone())
                    .or_default()
                    .insert(code.clone());
            }
        }
    }
    seen.into_iter()
        .map(|(key, by_name)| {
            let exact = by_name
                .into_iter()
                .filter(|(_, codes)| codes.len() == 1)
                .map(|(name, codes)| (name, codes.into_iter().next().unwrap()))
                .collect();
            (key, exact)
        })
        .collect()
}

/// Replace every name under a translated key by its code — plain strings and
/// the `code` of an enriched `{code, meaning, enum}` object. Returns the count.
fn swap(v: &mut Value, tables: &BTreeMap<String, BTreeMap<String, String>>) -> usize {
    let mut n = 0;
    match v {
        Value::Object(o) => {
            for (k, child) in o.iter_mut() {
                if let Some(table) = tables.get(k) {
                    let enriched = child.as_object().is_some_and(|e| e.contains_key("meaning"));
                    let slot = if enriched {
                        child.get_mut("code")
                    } else {
                        Some(&mut *child)
                    };
                    if let Some(Value::String(s)) = slot {
                        if let Some(code) = table.get(s.as_str()) {
                            *s = code.clone();
                            n += 1;
                            continue;
                        }
                    }
                }
                n += swap(child, tables);
            }
        }
        Value::Array(a) => n += a.iter_mut().map(|c| swap(c, tables)).sum::<usize>(),
        _ => {}
    }
    n
}

fn check(variant: &str, msg_dir: &str, pids: &[&str]) {
    let root = repo_root();
    if !root.join(format!("dist/edifact-data-{FV}.bin")).exists() {
        eprintln!("skipping: build the {FV} bundle first");
        return;
    }
    let mapper =
        Mapper::from_data_dir(DataDir::path(root.join("dist")).eager(&[FV])).expect("load bundle");
    let tables = names_to_codes(&root, variant);
    for pid in pids {
        let fixture = root.join(format!(
            "fixtures/generated/{}/{msg_dir}/{pid}.edi",
            FV.to_lowercase()
        ));
        let edifact = std::fs::read_to_string(&fixture)
            .unwrap_or_else(|e| panic!("{}: {e}", fixture.display()));
        let interchange = mapper
            .from_edifact::<Value, Value>(&edifact, FV, variant, pid)
            .unwrap_or_else(|e| panic!("{pid}: from_edifact: {e}"));
        let named = &interchange.nachrichten[0];
        let with_names = mapper
            .to_edifact_nachricht(named, FV, variant, pid)
            .unwrap_or_else(|e| panic!("{pid}: to_edifact with names: {e}"));

        let mut raw = serde_json::to_value(named).expect("serialize");
        let swapped = swap(&mut raw, &tables);
        assert!(
            swapped >= 3,
            "{pid}: only {swapped} name(s) swapped for codes — the test no longer exercises anything"
        );
        let raw: Nachricht<Value, Value> = serde_json::from_value(raw).expect("deserialize");
        let with_codes = mapper
            .to_edifact_nachricht(&raw, FV, variant, pid)
            .unwrap_or_else(|e| panic!("{pid}: to_edifact with raw codes: {e}"));

        assert_eq!(
            with_codes, with_names,
            "{pid}: {swapped} raw code(s) in place of their names rendered a different message"
        );
    }
}

#[test]
fn utilmd_raw_codes_render_like_their_names() {
    check("UTILMD_Strom", "utilmd", &["55001", "55043", "55168"]);
}

#[test]
fn iftsta_raw_codes_render_like_their_names() {
    check("IFTSTA", "iftsta", &["21010", "21011"]);
}