edifact-mapper 0.2.0

EDIFACT to BO4E bidirectional conversion for the German energy market
Documentation
//! CONTRL through the public `Mapper` facade.
//!
//! CONTRL was the only message type whose mapping directory stayed flat
//! (`mappings/<FV>/CONTRL/*.toml`, no `message/`, no `pid_*`), so
//! `compile-mappings` produced no definitions at all for it: the data bundle
//! carried `message_defs: []` and `transaction_defs: {"pid_": []}`. The
//! engine-level tests load the directory directly and never saw that — the
//! defect only shows at the layer every consumer actually uses.
//!
//! CONTRL has exactly one PID, 91001. Its AHB carries three AWF rows —
//! "Empfangsbestätigung", "Syntaxfehlermeldung in der Übertragungsdatei",
//! "Syntaxfehlermeldung in der Nachricht" — and leaves the
//! `Pruefidentifikator` empty on all three, because they are three things one
//! syntax acknowledgement can report, told apart inside the message by `UCI`
//! DE0083 and the presence of a `UCM`. The AHB parser merges them into that
//! single PID.
//!
//! What this test pins down:
//!
//!  1. `detect_pid` names 91001 for every CONTRL, whichever of the three
//!     reports it carries — it needs nothing but the `UNH` message type.
//!  2. `from_edifact` yields real entities — `uebertragungspruefung` from the
//!     `UCI` at message level, and one transaction per checked message
//!     (`SG1`/`UCM`) carrying its own `segmentfehler`.
//!  3. `to_edifact` rebuilds the message body byte-identically from that BO4E
//!     alone, for all three report shapes.

use std::path::PathBuf;

use edifact_mapper::{DataDir, Mapper};
use mig_assembly::pid_detect::CONTRL_PID;
use serde_json::Value;

const FORMAT_VERSIONS: [&str; 4] = ["FV2504", "FV2510", "FV2604", "FV2610"];

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .unwrap()
}

fn dist_dir() -> PathBuf {
    repo_root().join("dist")
}

fn segments(edifact: &str) -> Vec<String> {
    edifact
        .split('\'')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .collect()
}

fn tag_of(segment: &str) -> &str {
    segment.split(['+', ':']).next().unwrap_or(segment)
}

/// What `Mapper::to_edifact` must produce: the message body between UNH and
/// UNT, segment-terminated, without newlines.
fn expected_body(edifact: &str) -> String {
    segments(edifact)
        .into_iter()
        .filter(|s| !["UNA", "UNB", "UNH", "UNT", "UNZ"].contains(&tag_of(s)))
        .map(|s| format!("{s}'"))
        .collect()
}

/// A CONTRL interchange with `body` between UNH and UNT.
fn message(body: &[&str]) -> String {
    let mut segs: Vec<String> = vec![
        "UNB+UNOC:3+9903111000003:500+9900269000000:500+220614:1519+X315521".to_string(),
        "UNH+X315521+CONTRL:D:3:UN:2.0b".to_string(),
    ];
    segs.extend(body.iter().map(|s| (*s).to_string()));
    segs.push(format!("UNT+{}+X315521", body.len() + 2));
    segs.push("UNZ+1+X315521".to_string());
    segs.iter().map(|s| format!("{s}'")).collect()
}

/// An Empfangsbestätigung: `UCI` action 7, nothing else.
fn empfangsbestaetigung() -> String {
    message(&["UCI+10000000172UCI+9903111000003:500+9900269000000:500+7"])
}

/// A syntax error in the interchange itself: `UCI` action 4 carrying the error
/// code, the offending service segment and its position — and no `UCM`.
fn interchange_error() -> String {
    message(&["UCI+10000000172UCI+9903111000003:500+9900269000000:500+4+13+UNB+2:3"])
}

// ── 1. PID detection ──────────────────────────────────────────────────────

/// `(label, edifact)` — every one of them is PID 91001.
///
/// The first two are real CONTRL traffic from the corpus (an
/// Empfangsbestätigung and a message-level syntax error); the third is the
/// interchange-level shape the corpus has no sample of.
fn detection_cases() -> Vec<(String, String)> {
    let corpus = repo_root().join("example_market_communication_bo4e_transactions/CONTRL/FV2210");
    let mut out = Vec::new();
    for file in [
        "91001_CONTRL_2.0b_GPKELF18197957_pos.edi",
        "91001_CONTRL_2.0b_X3155219999460_neg.edi",
    ] {
        if let Ok(text) = std::fs::read_to_string(corpus.join(file)) {
            out.push((file.to_string(), text));
        }
    }
    out.push((
        "synthetic interchange-level syntax error".to_string(),
        interchange_error(),
    ));
    out
}

#[test]
fn every_contrl_reports_pid_91001() {
    let dist = dist_dir();
    if !dist.join("edifact-data-FV2504.bin").exists() {
        eprintln!("skipping: dist/ bundles missing");
        return;
    }
    let mapper = Mapper::from_data_dir(DataDir::path(&dist)).expect("load bundles");

    let cases = detection_cases();
    assert!(
        cases.len() >= 3,
        "expected the CONTRL corpus fixtures to be checked out"
    );
    let mut failures = Vec::new();
    for (label, edifact) in cases {
        match mapper.detect_pid(&edifact) {
            Ok(pid) if pid == CONTRL_PID => {}
            Ok(pid) => failures.push(format!("{label}: detected {pid}, expected {CONTRL_PID}")),
            Err(e) => failures.push(format!("{label}: detect_pid failed: {e}")),
        }
    }
    assert!(failures.is_empty(), "{}", failures.join("\n"));
}

// ── 2./3. forward + reverse through the bundle ────────────────────────────

/// Which report shape a case feeds in.
#[derive(Clone, Copy)]
enum Shape {
    /// `fixtures/generated/<fv>/contrl/91001.edi` — the union the merged PID
    /// describes: a `UCI` with its error fields *and* a `UCM` with `UCS`/`UCD`.
    GeneratedFixture,
    /// `UCI` action 7 and nothing else.
    Empfangsbestaetigung,
    /// `UCI` action 4 with the error on the interchange, no `UCM`.
    InterchangeError,
}

struct Case {
    fv: &'static str,
    shape: Shape,
    /// One transaction per checked message (`SG1`/`UCM`).
    checked_messages: usize,
}

/// Every format version's generated fixture, plus the two report shapes it does
/// not cover. A real CONTRL often carries less than the union, and those shapes
/// must map and reverse just as exactly.
const CASES: &[Case] = &[
    Case {
        fv: "FV2504",
        shape: Shape::GeneratedFixture,
        checked_messages: 1,
    },
    Case {
        fv: "FV2510",
        shape: Shape::GeneratedFixture,
        checked_messages: 1,
    },
    Case {
        fv: "FV2604",
        shape: Shape::GeneratedFixture,
        checked_messages: 1,
    },
    Case {
        fv: "FV2610",
        shape: Shape::GeneratedFixture,
        checked_messages: 1,
    },
    Case {
        fv: "FV2504",
        shape: Shape::Empfangsbestaetigung,
        checked_messages: 0,
    },
    Case {
        fv: "FV2610",
        shape: Shape::Empfangsbestaetigung,
        checked_messages: 0,
    },
    Case {
        fv: "FV2504",
        shape: Shape::InterchangeError,
        checked_messages: 0,
    },
    Case {
        fv: "FV2610",
        shape: Shape::InterchangeError,
        checked_messages: 0,
    },
];

fn label_of(case: &Case) -> String {
    let shape = match case.shape {
        Shape::GeneratedFixture => "generated fixture",
        Shape::Empfangsbestaetigung => "Empfangsbestätigung (UCI only)",
        Shape::InterchangeError => "interchange-level syntax error",
    };
    format!("{}/{shape}", case.fv)
}

/// The EDIFACT a case feeds in, or `None` when its fixture is missing.
fn edifact_for(case: &Case) -> Option<String> {
    match case.shape {
        Shape::GeneratedFixture => {
            let path = repo_root()
                .join("fixtures/generated")
                .join(case.fv.to_lowercase())
                .join("contrl/91001.edi");
            std::fs::read_to_string(path).ok()
        }
        Shape::Empfangsbestaetigung => Some(empfangsbestaetigung()),
        Shape::InterchangeError => Some(interchange_error()),
    }
}

#[test]
fn contrl_maps_to_entities_and_back() {
    let dist = dist_dir();
    if !dist.join("edifact-data-FV2504.bin").exists() {
        eprintln!("skipping: dist/ bundles missing");
        return;
    }

    let mut failures: Vec<String> = Vec::new();
    for case in CASES {
        let label = label_of(case);
        let Some(edifact) = edifact_for(case) else {
            failures.push(format!("{label}: fixture missing"));
            continue;
        };
        let mapper =
            Mapper::from_data_dir(DataDir::path(&dist).eager(&[case.fv])).expect("load bundle");

        let ic = match mapper.from_edifact::<Value, Value>(&edifact, case.fv, "CONTRL", CONTRL_PID)
        {
            Ok(ic) => ic,
            Err(e) => {
                failures.push(format!("{label}: from_edifact failed: {e}"));
                continue;
            }
        };
        let nachricht = &ic.nachrichten[0];

        // The UCI acknowledgement is message-level stammdaten.
        let pruefung = nachricht.stammdaten.get("uebertragungspruefung");
        match pruefung.and_then(|p| p.get("datenaustauschreferenz")) {
            Some(Value::String(r)) if !r.is_empty() => {}
            _ => failures.push(format!(
                "{label}: `uebertragungspruefung.datenaustauschreferenz` missing; stammdaten = {}",
                nachricht.stammdaten
            )),
        }

        // Each checked message (SG1/UCM) is one transaction, carrying its own
        // segment errors.
        if nachricht.transaktionen.len() != case.checked_messages {
            failures.push(format!(
                "{label}: expected {} transaction(s), got {}: {:?}",
                case.checked_messages,
                nachricht.transaktionen.len(),
                nachricht.transaktionen
            ));
        } else {
            for (i, tx) in nachricht.transaktionen.iter().enumerate() {
                let stamm = tx.get("stammdaten").unwrap_or(tx);
                let Some(np) = stamm.get("nachrichtenpruefung") else {
                    failures.push(format!(
                        "{label}: transaction {i} has no `nachrichtenpruefung`: {stamm}"
                    ));
                    continue;
                };
                if np
                    .get("nachrichtenReferenznummer")
                    .and_then(Value::as_str)
                    .is_none()
                {
                    failures.push(format!(
                        "{label}: transaction {i} `nachrichtenpruefung.nachrichtenReferenznummer` missing: {np}"
                    ));
                }
                let errors = np.get("segmentfehler").and_then(Value::as_array);
                if errors.is_none_or(Vec::is_empty) {
                    failures.push(format!(
                        "{label}: transaction {i} carries no nested `segmentfehler`: {np}"
                    ));
                }
            }
        }

        // Reverse from the BO4E alone.
        let mut msg = nachricht.stammdaten.clone();
        mig_bo4e::model::restore_message_metadata(&mut msg, &nachricht.nachrichtendaten);
        match mapper.to_edifact(
            &msg,
            &nachricht.transaktionen,
            case.fv,
            "CONTRL",
            CONTRL_PID,
        ) {
            Ok(rendered) => {
                let want = expected_body(&edifact);
                if rendered != want {
                    failures.push(format!(
                        "{label}: reverse is not byte-identical\n  want: {want}\n  got:  {rendered}"
                    ));
                }
            }
            Err(e) => failures.push(format!("{label}: to_edifact failed: {e}")),
        }
    }

    assert!(
        failures.is_empty(),
        "CONTRL is not reachable through `Mapper`:\n{}",
        failures.join("\n")
    );
}

/// The bundle must carry CONTRL's definitions under the one PID it has — an
/// empty `transaction_defs` entry would let the roundtrip above pass vacuously,
/// and a leftover `pid_`, `pid_91002` or `pid_91003` entry would mean the
/// generator still numbers CONTRL's AWF rows separately.
#[test]
fn contrl_bundles_carry_exactly_one_pid() {
    let dist = dist_dir();
    let expected_key = format!("pid_{CONTRL_PID}");
    let mut failures = Vec::new();
    for fv in FORMAT_VERSIONS {
        let path = dist.join(format!("edifact-data-{fv}.bin"));
        if !path.exists() {
            eprintln!("skipping {fv}: bundle missing");
            continue;
        }
        let bundle = mig_bo4e::engine::DataBundle::load(&path).expect("load bundle");
        let Some(vc) = bundle.variant("CONTRL") else {
            failures.push(format!("{fv}: no CONTRL variant in bundle"));
            continue;
        };
        if vc.message_defs.is_empty() {
            failures.push(format!("{fv}: CONTRL message_defs is empty"));
        }
        let pids: Vec<&String> = vc.transaction_defs.keys().collect();
        if pids != vec![&expected_key] {
            failures.push(format!(
                "{fv}: CONTRL PIDs are {pids:?}, expected only [{expected_key}]"
            ));
        }
        match vc.transaction_defs.get(&expected_key) {
            Some(defs) if defs.len() >= 2 => {}
            other => failures.push(format!(
                "{fv}: CONTRL {expected_key} must map SG1 and SG1.SG2, got {:?}",
                other.map(Vec::len)
            )),
        }
        // The union of all three AWF rows' segment numbers: UNH, UCI, UCM, UCS,
        // UCD, UNT. Without it the PID-filtered MIG cannot assemble a report
        // that descends into a checked message.
        match vc.pid_segment_numbers.get(&expected_key) {
            Some(numbers) if numbers.len() == 6 => {}
            other => failures.push(format!(
                "{fv}: CONTRL {expected_key} must carry all six segment numbers, got {other:?}"
            )),
        }
    }
    assert!(failures.is_empty(), "{}", failures.join("\n"));
}