edifact-mapper 0.13.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! `from_edifact_with(CodeForm::Raw)` writes codes as they stand on the wire.
//!
//! Names belong to the release that wrote them — a release may rename a code
//! to its AHB meaning, and a stored name the current table no longer has
//! reaches the wire verbatim. A caller that stores or replays BO4E across
//! releases needs the codes, and should not have to rebuild the tables from
//! engine internals to get them back.

use edifact_mapper::{CodeForm, DataDir, FromEdifactOptions, Mapper};
use serde_json::Value;
use std::path::PathBuf;

const FV: &str = "FV2604";

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

fn setup() -> Option<Mapper> {
    let root = repo_root();
    if !root.join(format!("dist/edifact-data-{FV}.bin")).exists() {
        eprintln!("skipping: build the {FV} bundle first");
        return None;
    }
    Some(Mapper::from_data_dir(DataDir::path(root.join("dist")).eager(&[FV])).expect("load bundle"))
}

fn fixture(msg_dir: &str, pid: &str) -> String {
    let path = repo_root().join(format!(
        "fixtures/generated/{}/{msg_dir}/{pid}.edi",
        FV.to_lowercase()
    ));
    std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}

const RAW: FromEdifactOptions = FromEdifactOptions {
    codes: CodeForm::Raw,
};

/// Every value under `key`, anywhere in `v` — a plain string or the `code` of
/// an enriched `{code, meaning}` object.
fn values_of(v: &Value, key: &str, out: &mut Vec<String>) {
    match v {
        Value::Object(o) => {
            for (k, child) in o {
                if k == key {
                    match child {
                        Value::String(s) => out.push(s.clone()),
                        Value::Object(e) => {
                            if let Some(Value::String(s)) = e.get("code") {
                                out.push(s.clone());
                            }
                        }
                        _ => {}
                    }
                }
                values_of(child, key, out);
            }
        }
        Value::Array(a) => a.iter().for_each(|c| values_of(c, key, out)),
        _ => {}
    }
}

/// The codes the wire carries at `tag+<code>` (the segment's first element).
fn wire_qualifiers(edifact: &str, tag: &str) -> Vec<String> {
    edifact
        .split('\'')
        .map(str::trim)
        .filter_map(|s| s.strip_prefix(&format!("{tag}+")))
        .map(|rest| rest.split(['+', ':']).next().unwrap_or("").to_string())
        .collect()
}

#[test]
fn raw_codes_render_the_same_message_as_names() {
    let Some(mapper) = setup() else {
        return;
    };
    for (variant, dir, pid) in [
        ("UTILMD_Strom", "utilmd", "55001"),
        ("UTILMD_Strom", "utilmd", "55043"),
        ("UTILMD_Strom", "utilmd", "55168"),
        ("IFTSTA", "iftsta", "21010"),
        ("IFTSTA", "iftsta", "21011"),
    ] {
        let edifact = fixture(dir, pid);
        let named = mapper
            .from_edifact::<Value, Value>(&edifact, FV, variant, pid)
            .expect("names");
        let raw = mapper
            .from_edifact_with::<Value, Value>(&edifact, FV, variant, pid, &RAW)
            .expect("raw");
        assert_ne!(
            serde_json::to_value(&named.nachrichten[0]).unwrap(),
            serde_json::to_value(&raw.nachrichten[0]).unwrap(),
            "{pid}: raw codes changed nothing — the option does not reach the engine"
        );
        let from_names = mapper
            .to_edifact_nachricht(&named.nachrichten[0], FV, variant, pid)
            .expect("render names");
        let from_raw = mapper
            .to_edifact_nachricht(&raw.nachrichten[0], FV, variant, pid)
            .expect("render raw");
        assert_eq!(from_raw, from_names, "{pid}: raw codes render differently");
    }
}

/// Code fields hold the wire's codes: every SG12 NAD qualifier as `partnerrolle`,
/// the BGM document code as `nachrichtentyp`.
#[test]
fn code_fields_hold_the_wire_codes() {
    let Some(mapper) = setup() else {
        return;
    };
    let edifact = fixture("utilmd", "55043");
    let raw = mapper
        .from_edifact_with::<Value, Value>(&edifact, FV, "UTILMD_Strom", "55043", &RAW)
        .expect("raw");
    let json = serde_json::to_value(&raw.nachrichten[0]).unwrap();

    let mut roles = Vec::new();
    values_of(&json, "partnerrolle", &mut roles);
    // SG12's NADs; NAD+MS/MR are the message's Marktteilnehmer.
    let mut wire: Vec<String> = wire_qualifiers(&edifact, "NAD")
        .into_iter()
        .filter(|q| q != "MS" && q != "MR")
        .collect();
    roles.sort();
    wire.sort();
    assert_eq!(roles, wire, "partnerrolle should be the NAD qualifiers");

    let mut types = Vec::new();
    values_of(&json, "nachrichtentyp", &mut types);
    assert_eq!(types, wire_qualifiers(&edifact, "BGM"));
}

/// Enrichment still explains a raw code: `{code: "<wire code>", meaning}`.
#[test]
fn enriched_raw_codes_keep_their_meaning() {
    let Some(mapper) = setup() else {
        return;
    };
    let edifact = fixture("utilmd", "55043");
    let raw = mapper
        .from_edifact_with::<Value, Value>(&edifact, FV, "UTILMD_Strom", "55043", &RAW)
        .expect("raw");
    let json = serde_json::to_value(&raw.nachrichten[0]).unwrap();
    let mut enriched = Vec::new();
    fn walk(v: &Value, out: &mut Vec<(String, Value)>) {
        match v {
            Value::Object(o) => {
                if let (Some(Value::String(c)), Some(m)) = (o.get("code"), o.get("meaning")) {
                    out.push((c.clone(), m.clone()));
                }
                o.values().for_each(|c| walk(c, out));
            }
            Value::Array(a) => a.iter().for_each(|c| walk(c, out)),
            _ => {}
        }
    }
    walk(&json, &mut enriched);
    assert!(!enriched.is_empty(), "no enriched code in the raw output");
    let explained = enriched.iter().filter(|(_, m)| m.is_string()).count();
    assert!(
        explained * 2 >= enriched.len(),
        "most raw codes should carry their AHB meaning: {explained}/{}",
        enriched.len()
    );
}