use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use edifact_mapper::{DataDir, Mapper};
use mig_bo4e::definition::{FieldMapping, MappingDefinition};
use mig_bo4e::engine::{DataBundle, VariantCache};
use mig_types::schema::mig::{MigSchema, MigSegmentGroup};
use serde_json::Value;
const KNOWN_FAILURES: &[(&[&str], &str, &str)] = &[(
&["FV2604"],
"mscons/13024",
"the FV2604 data bundle has no AHB segment numbers for PID 13024, so \
`from_edifact` fails before any reverse mapping runs (\"No MIG schema\")",
)];
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 to_camel_case(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_lowercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
type QualifierPos = (String, usize, usize, String);
fn mig_groups_at<'a>(mig: &'a MigSchema, source_group: &str) -> Vec<&'a MigSegmentGroup> {
let mut level: Vec<&MigSegmentGroup> = mig.segment_groups.iter().collect();
let mut found = Vec::new();
let parts: Vec<&str> = source_group
.split('.')
.map(|p| p.split(':').next().unwrap_or(p))
.collect();
for (i, part) in parts.iter().enumerate() {
found = level.iter().copied().filter(|g| g.id == *part).collect();
if i + 1 < parts.len() {
level = found.iter().flat_map(|g| g.nested_groups.iter()).collect();
}
}
found
}
fn qualifier_positions(mig: &MigSchema, source_group: &str) -> Vec<QualifierPos> {
let mut out = Vec::new();
for g in mig_groups_at(mig, source_group) {
let Some(entry) = g.segments.first() else {
continue;
};
let tag = entry.id.to_lowercase();
let mut coded: Vec<(usize, usize, String)> = entry
.data_elements
.iter()
.filter(|d| !d.codes.is_empty())
.map(|d| (d.position, 0, d.id.clone()))
.collect();
for c in &entry.composites {
for d in &c.data_elements {
if !d.codes.is_empty() {
coded.push((c.position, d.position, d.id.clone()));
}
}
}
coded.sort();
if let Some((e, c, id)) = coded.into_iter().next() {
let pos = (tag.clone(), e, c, id);
if !out.contains(&pos) {
out.push(pos);
}
}
}
out
}
fn path_addresses(path: &str, pos: &QualifierPos) -> bool {
let mut parts = path.split('.');
let Some(head) = parts.next() else {
return false;
};
let tag = head.split('[').next().unwrap_or(head);
if tag != pos.0 {
return false;
}
let rest: Vec<&str> = parts.collect();
if rest.last().is_some_and(|l| {
l.strip_prefix('d')
.map(|id| id.split('_').next() == Some(pos.3.as_str()))
.unwrap_or(false)
}) {
return true;
}
let nums: Option<Vec<usize>> = rest.iter().map(|p| p.parse().ok()).collect();
match nums.as_deref() {
Some([e]) => *e == pos.1 && pos.2 == 0,
Some([e, c]) => *e == pos.1 && *c == pos.2,
_ => false,
}
}
fn target_of(mapping: &FieldMapping) -> Vec<String> {
match mapping {
FieldMapping::Simple(t) => vec![t.clone()],
FieldMapping::Structured(s) => {
let mut v = vec![s.target.clone()];
v.extend(s.also_target.clone());
v
}
FieldMapping::Nested(_) => vec![],
}
}
fn qualifier_fields(defs: &[&MappingDefinition], mig: &MigSchema) -> BTreeMap<String, Vec<String>> {
let key_of = |def: &MappingDefinition| match &def.meta.parent_field {
Some(pf) => pf.clone(),
None => to_camel_case(&def.meta.entity),
};
let depth = |def: &MappingDefinition| def.meta.source_group.split('.').count();
let mut min_depth: BTreeMap<String, usize> = BTreeMap::new();
for def in defs {
if def.meta.repeat_on_tag.is_some() || def.meta.source_group.is_empty() {
continue;
}
let d = min_depth.entry(key_of(def)).or_insert(usize::MAX);
*d = (*d).min(depth(def));
}
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for def in defs {
if def.meta.repeat_on_tag.is_some() || def.meta.source_group.is_empty() {
continue;
}
let key = key_of(def);
if min_depth.get(&key) != Some(&depth(def)) {
continue;
}
let positions = qualifier_positions(mig, &def.meta.source_group);
let entry = out.entry(key).or_default();
for (path, mapping) in &def.fields {
if positions.iter().any(|p| path_addresses(path, p)) {
for t in target_of(mapping) {
if !t.is_empty() && !entry.contains(&t) {
entry.push(t);
}
}
}
}
}
out.retain(|_, v| !v.is_empty());
out
}
fn get_dotted<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
path.split('.').try_fold(v, |cur, p| cur.get(p))
}
fn code_of(v: &Value) -> Value {
match v.get("code") {
Some(c) if v.get("meaning").is_some() => c.clone(),
_ => v.clone(),
}
}
#[derive(Clone, Copy, Debug)]
enum Perm {
Reverse,
Rotate,
}
fn permute(items: &[Value], fields: &[String], perm: Perm) -> Option<Vec<Value>> {
let keys: Vec<String> = items
.iter()
.map(|it| {
fields
.iter()
.map(|f| get_dotted(it, f).map(code_of).unwrap_or(Value::Null))
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join("|")
})
.collect();
if keys.iter().any(|k| k.split('|').all(|p| p == "null")) {
return None;
}
let mut order: Vec<&String> = Vec::new();
for k in &keys {
if !order.contains(&k) {
order.push(k);
}
}
if order.len() < 2 {
return None;
}
match perm {
Perm::Reverse => order.reverse(),
Perm::Rotate => order.rotate_left(1),
}
let mut out = Vec::with_capacity(items.len());
for k in order {
for (it, key) in items.iter().zip(&keys) {
if key == k {
out.push(it.clone());
}
}
}
Some(out)
}
fn permute_value(
v: &mut Value,
label: &str,
qf: &BTreeMap<String, Vec<String>>,
perm: Perm,
only: Option<&str>,
changed: &mut Vec<String>,
) {
match v {
Value::Object(map) => {
for (k, child) in map.iter_mut() {
let child_label = format!("{label}.{k}");
if let (Value::Array(items), Some(fields)) = (&*child, qf.get(k)) {
if only.map_or(true, |o| o == child_label) {
if let Some(p) = permute(items, fields, perm) {
*child = Value::Array(p);
changed.push(child_label.clone());
}
}
}
permute_value(child, &child_label, qf, perm, only, changed);
}
}
Value::Array(items) => {
for it in items.iter_mut() {
permute_value(it, &format!("{label}[]"), qf, perm, only, changed);
}
}
_ => {}
}
}
fn parent_variants_with_child(
mig: &MigSchema,
numbers: &[String],
parent_group: &str,
child_group: &str,
) -> usize {
let in_pid = |g: &MigSegmentGroup| {
g.segments
.first()
.and_then(|s| s.number.as_ref())
.map_or(true, |n| numbers.contains(n))
};
let Some(next_id) = child_group
.strip_prefix(parent_group)
.and_then(|rest| rest.strip_prefix('.'))
.and_then(|rest| rest.split('.').next())
else {
return 0;
};
mig_groups_at(mig, parent_group)
.into_iter()
.filter(|g| in_pid(g))
.filter(|g| g.nested_groups.iter().any(|c| c.id == next_id && in_pid(c)))
.count()
}
fn positional_pairings(
defs: &[&MappingDefinition],
scope: &Value,
qf: &BTreeMap<String, Vec<String>>,
tx_group: Option<&str>,
mig: &MigSchema,
numbers: &[String],
) -> Vec<String> {
let Some(obj) = scope.as_object() else {
return Vec::new();
};
let mut groups: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();
for def in defs {
if def.meta.parent_field.is_some() || def.meta.source_group.is_empty() {
continue;
}
groups
.entry(to_camel_case(&def.meta.entity))
.or_default()
.insert(def.meta.source_group.as_str());
}
let mut out = Vec::new();
for (parent, parent_groups) in &groups {
let Some(Value::Array(items)) = obj.get(parent) else {
continue;
};
let distinguishable = qf
.get(parent)
.is_some_and(|fields| permute(items, fields, Perm::Reverse).is_some());
if !distinguishable {
continue;
}
for (child, child_groups) in &groups {
let present = obj
.get(child)
.is_some_and(|v| v.as_array().map_or(v.is_object(), |a| !a.is_empty()));
if child == parent || !present || !child_groups.is_disjoint(parent_groups) {
continue;
}
let below = child_groups.iter().any(|c| {
parent_groups.iter().any(|p| {
Some(*p) != tx_group && parent_variants_with_child(mig, numbers, p, c) >= 2
})
});
if below {
out.push(format!("`{child}` paired with `{parent}[]` by position"));
}
}
}
out
}
enum Outcome {
Pass { permuted: usize },
Skip(String),
Fail(String),
}
fn first_diff(a: &str, b: &str) -> String {
let sa: Vec<&str> = a.split('\'').collect();
let sb: Vec<&str> = b.split('\'').collect();
sa.iter()
.zip(&sb)
.find(|(x, y)| x != y)
.map(|(x, y)| format!("{x}' vs {y}'"))
.unwrap_or_else(|| format!("{} vs {} segments", sa.len(), sb.len()))
}
#[allow(clippy::too_many_arguments)]
fn check_fixture(
mapper: &Mapper,
vc: &VariantCache,
fv: &str,
variant: &str,
pid: &str,
edifact: &str,
) -> Outcome {
let ic = match mapper.from_edifact::<Value, Value>(edifact, fv, variant, pid) {
Ok(ic) => ic,
Err(e) => return Outcome::Fail(format!("forward error: {e}")),
};
let Some(mig) = vc.mig_schema.as_ref() else {
return Outcome::Skip("no MIG".into());
};
let msg_defs: Vec<&MappingDefinition> = vc.message_defs.iter().collect();
let tx_defs: Vec<&MappingDefinition> = vc
.transaction_defs
.get(&format!("pid_{pid}"))
.map(|d| d.iter().collect())
.unwrap_or_default();
let qf_msg = qualifier_fields(&msg_defs, mig);
let qf_tx = qualifier_fields(&tx_defs, mig);
let n = &ic.nachrichten[0];
let msg = n.stammdaten.clone();
let txs = n.transaktionen.clone();
let tx_group = vc.tx_group(pid).filter(|g| !g.is_empty());
let numbers = vc
.pid_segment_numbers
.get(&format!("pid_{pid}"))
.cloned()
.unwrap_or_default();
let mut pairings = positional_pairings(&msg_defs, &msg, &qf_msg, None, mig, &numbers);
for tx in &txs {
for p in positional_pairings(&tx_defs, tx, &qf_tx, tx_group, mig, &numbers) {
if !pairings.contains(&p) {
pairings.push(p);
}
}
}
if !pairings.is_empty() {
return Outcome::Fail(format!("positional pairing: {}", pairings.join("; ")));
}
let render = |m: &Value, t: &[Value]| mapper.to_edifact(m, t, fv, variant, pid);
let original = match render(&msg, &txs) {
Ok(s) => s,
Err(e) => return Outcome::Skip(format!("unpermuted JSON does not render: {e}")),
};
let apply = |perm: Perm, only: Option<&str>| {
let mut changed = Vec::new();
let mut m = msg.clone();
permute_value(&mut m, "message", &qf_msg, perm, only, &mut changed);
let mut t = txs.clone();
for (i, tx) in t.iter_mut().enumerate() {
permute_value(tx, &format!("tx[{i}]"), &qf_tx, perm, only, &mut changed);
}
(m, t, changed)
};
let mut permuted = 0;
for perm in [Perm::Reverse, Perm::Rotate] {
let (m, t, changed) = apply(perm, None);
if changed.is_empty() {
continue;
}
permuted += 1;
let result = render(&m, &t);
if let Some(dir) = std::env::var_os("PERMUTATION_GATE_DUMP") {
let path = PathBuf::from(dir).join(format!("{fv}_{variant}_{pid}_{perm:?}.json"));
let json = serde_json::json!({
"original": { "message": msg, "transaktionen": txs, "edifact": original },
"permuted": { "message": m, "transaktionen": t,
"edifact": result.as_ref().map_err(|e| e.to_string()) },
});
std::fs::write(path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
}
if result.as_deref().ok() == Some(original.as_str()) {
continue;
}
let mut culprits = Vec::new();
for label in &changed {
let (m1, t1, _) = apply(perm, Some(label));
match render(&m1, &t1) {
Ok(s) if s == original => {}
Ok(s) => culprits.push(format!("{label} ({})", first_diff(&original, &s))),
Err(e) => culprits.push(format!("{label} (render error: {e})")),
}
}
if culprits.is_empty() {
culprits.push(match result {
Ok(s) => format!("combined only: {}", first_diff(&original, &s)),
Err(e) => format!("combined only: render error {e}"),
});
}
return Outcome::Fail(format!("{perm:?}: {}", culprits.join("; ")));
}
Outcome::Pass { permuted }
}
fn resolve(
mapper: &Mapper,
bundle: &DataBundle,
msg: &str,
stem: &str,
edifact: &str,
) -> Option<(String, String)> {
let detected = mapper.detect_pid(edifact).ok();
let mut variants: Vec<&String> = bundle.variants.keys().collect();
variants.sort();
for variant in variants {
let lower = variant.to_lowercase();
if lower != msg && !lower.starts_with(&format!("{msg}_")) {
continue;
}
let vc = bundle.variant(variant).unwrap();
let mut candidates = vec![stem.to_string()];
candidates.extend(detected.clone());
if vc.tx_groups.len() == 1 {
let only = vc.tx_groups.keys().next().unwrap();
candidates.push(only.trim_start_matches("pid_").to_string());
}
candidates.push(String::new());
for pid in candidates {
if vc.tx_group(&pid).is_some() {
return Some((variant.to_string(), pid));
}
}
}
None
}
fn run_gate(fv: &str) {
let root = repo_root();
let dist = root.join("dist");
let bundle_path = dist.join(format!("edifact-data-{fv}.bin"));
let fixtures = root.join("fixtures/generated").join(fv.to_lowercase());
if !bundle_path.exists() || !fixtures.is_dir() {
eprintln!("skipping {fv}: bundle or fixtures missing");
return;
}
let mapper = Mapper::from_data_dir(DataDir::path(&dist).eager(&[fv])).expect("load bundle");
let bundle = DataBundle::load(&bundle_path).expect("load bundle");
let mut report: BTreeMap<String, BTreeMap<&str, usize>> = BTreeMap::new();
let mut failures: Vec<(String, String)> = Vec::new();
let mut skips: Vec<(String, String)> = Vec::new();
let mut files: Vec<PathBuf> = walk(&fixtures);
files.sort();
for file in files {
let msg = file
.parent()
.and_then(|p| p.file_name())
.unwrap()
.to_string_lossy()
.to_string();
let stem = file.file_stem().unwrap().to_string_lossy().to_string();
let key = format!("{msg}/{stem}");
let edifact = std::fs::read_to_string(&file).unwrap();
let entry = report.entry(msg.clone()).or_default();
let Some((variant, pid)) = resolve(&mapper, &bundle, &msg, &stem, &edifact) else {
*entry.entry("skip").or_default() += 1;
skips.push((key, "cannot resolve variant/PID".into()));
continue;
};
let vc = bundle.variant(&variant).unwrap();
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
check_fixture(&mapper, vc, fv, &variant, &pid, &edifact)
}))
.unwrap_or_else(|p| {
let detail = p
.downcast_ref::<String>()
.cloned()
.or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
Outcome::Fail(format!("panic: {detail}"))
});
match outcome {
Outcome::Pass { permuted: 0 } => *entry.entry("pass-unpermuted").or_default() += 1,
Outcome::Pass { .. } => *entry.entry("pass").or_default() += 1,
Outcome::Skip(why) => {
*entry.entry("skip").or_default() += 1;
skips.push((key, why));
}
Outcome::Fail(why) => {
*entry.entry("FAIL").or_default() += 1;
failures.push((key, why));
}
}
}
eprintln!("\n=== reverse-order permutation gate {fv} ===");
for (msg, counts) in &report {
let line: Vec<String> = counts.iter().map(|(k, c)| format!("{k}={c}")).collect();
eprintln!("{msg:>8}: {}", line.join(" "));
}
for (key, why) in &skips {
eprintln!("[skip] {key}: {why}");
}
let known: BTreeSet<&str> = KNOWN_FAILURES
.iter()
.filter(|(fvs, _, _)| fvs.contains(&fv))
.map(|(_, k, _)| *k)
.collect();
for (key, why) in &failures {
let marker = if known.contains(key.as_str()) {
"known"
} else {
"FAIL"
};
let short: String = why.chars().take(700).collect();
eprintln!("[{marker}] {key}: {short}");
}
let unexpected = failures
.iter()
.filter(|(k, _)| !known.contains(k.as_str()))
.count();
let stale: Vec<&&str> = known
.iter()
.filter(|k| !failures.iter().any(|(key, _)| key == **k))
.collect();
assert!(
unexpected == 0 && stale.is_empty(),
"{fv}: {unexpected} fixture(s) render differently when entity arrays are permuted; \
stale KNOWN_FAILURES entries: {stale:?}"
);
}
fn walk(dir: &PathBuf) -> Vec<PathBuf> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
out.extend(walk(&p));
} else if p.extension().is_some_and(|x| x == "edi") {
out.push(p);
}
}
out
}
#[test]
fn permutation_gate_fv2504() {
run_gate("FV2504");
}
#[test]
fn permutation_gate_fv2510() {
run_gate("FV2510");
}
#[test]
fn permutation_gate_fv2604() {
run_gate("FV2604");
}
#[test]
fn permutation_gate_fv2610() {
run_gate("FV2610");
}