1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//! 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.
//!
//! A rule may hold its table inline or NAME a shared one in
//! `mappings/code_lists.toml` (`code_list` / `also_code_list`). Both are judged
//! here, resolved to the same table. Reading only the inline form is how this
//! gate went blind to 5306 rules the day they were migrated to names -- the
//! baseline did not move, because the rules had left the gate's view entirely.
//!
//! Three properties, in this order:
//! 1. every name resolves, and every table is used -- a typo must fail here
//! rather than read as "this rule translates nothing";
//! 2. the table is injective, or jointly injective with its companion;
//! 3. where codes collide, the companion has an entry for every colliding
//! code. Without that a code the companion omits falls through to the
//! single-table lookup and comes back as the first key.
//!
//! Regenerate: `UPDATE_ENUM_COLLISIONS=1 cargo test -p mig-bo4e --test enum_map_injectivity_gate`
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// The shared tables a `code_list` name resolves against.
fn shared_lists() -> BTreeMap<String, BTreeMap<String, String>> {
let path = mappings_root().join("code_lists.toml");
let Ok(text) = std::fs::read_to_string(&path) else {
return BTreeMap::new();
};
toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
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> {
collisions_and_used().0
}
/// The collisions, and the names any rule referenced — so an unused table can
/// be reported too.
fn collisions_and_used() -> (BTreeSet<String>, BTreeSet<String>) {
let root = mappings_root();
let lists = shared_lists();
let mut used: BTreeSet<String> = BTreeSet::new();
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 rel_for_err = entry
.strip_prefix(&root)
.unwrap_or(&entry)
.display()
.to_string();
let resolve = |inline: &str, named: &str| -> Option<BTreeMap<String, String>> {
if let Some(t) = m.get(inline).and_then(|e| e.as_table()) {
return Some(
t.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect(),
);
}
let name = m.get(named).and_then(|e| e.as_str())?;
Some(lists.get(name).cloned().unwrap_or_else(|| {
panic!(
"{rel_for_err} {field_path}: `{named} = \"{name}\"` names no table in \
mappings/code_lists.toml — a name that resolves to nothing reads as \
\"this rule translates nothing\" and the code reaches the output raw"
)
}))
};
if let Some(n) = m.get("code_list").and_then(|e| e.as_str()) {
used.insert(n.to_string());
}
if let Some(n) = m.get("also_code_list").and_then(|e| e.as_str()) {
used.insert(n.to_string());
}
let Some(enum_map) = resolve("enum_map", "code_list") else {
continue;
};
let values: Vec<&str> = enum_map.values().map(String::as_str).collect();
if values.iter().collect::<BTreeSet<_>>().len() == values.len() {
continue; // injective on its own
}
// Jointly injective? the companion keys the same codes.
if let Some(also) = resolve("also_enum_map", "also_code_list") {
let colliding: Vec<&String> = {
let mut seen = BTreeMap::new();
for (c, v) in &enum_map {
seen.entry(v.clone()).or_insert_with(Vec::new).push(c);
}
seen.into_values()
.filter(|g| g.len() > 1)
.flatten()
.collect()
};
// every colliding code needs a companion entry, or it falls
// through to first-key-wins with no signal
let covered = colliding.iter().all(|c| also.contains_key(*c));
let joint: Vec<(&str, Option<&str>)> = enum_map
.iter()
.map(|(code, v)| (v.as_str(), also.get(code).map(String::as_str)))
.collect();
if covered && joint.iter().collect::<BTreeSet<_>>().len() == joint.len() {
continue;
}
}
let target = m.get("target").and_then(|t| t.as_str()).unwrap_or("?");
found.insert(format!("{rel_for_err} {field_path} {target}"));
}
}
(found, used)
}
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());
}
/// Reference integrity, checked before invertibility: a name that resolves to
/// nothing reads exactly like a rule that translates nothing, so it has to
/// fail loudly rather than quietly widen the "no collisions" claim.
#[test]
fn every_named_code_list_resolves_and_is_used() {
let lists = shared_lists();
let (_, used) = collisions_and_used();
// `collisions_and_used` panics on an unresolvable name, so reaching here
// means every reference resolved. What remains is the other direction.
let defined: BTreeSet<String> = lists.keys().cloned().collect();
let unused: Vec<&String> = defined.difference(&used).collect();
assert!(
lists.is_empty() || !used.is_empty(),
"mappings/code_lists.toml defines {} table(s) but no rule names one — \
either the file is dead or the gate stopped seeing the references",
lists.len()
);
assert!(
unused.is_empty(),
"{} shared table(s) are defined but referenced by no rule, so nothing \
keeps them honest: {:?}",
unused.len(),
unused
);
}