use mockserver_client::Expectation;
use serde_json::{Map, Value};
use std::collections::BTreeSet;
use std::path::PathBuf;
const MULTI: &[&str] = &["headers", "queryStringParameters", "trailers"];
const SINGLE: &[&str] = &["cookies"];
fn canon_multi(v: &Value) -> Map<String, Value> {
let mut out = Map::new();
match v {
Value::Object(m) => {
for (k, val) in m {
out.insert(k.clone(), as_value_list(val));
}
}
Value::Array(a) => {
for e in a {
if let Some(name) = e.get("name").and_then(Value::as_str) {
let vals = e.get("values").or_else(|| e.get("value"));
let vals = vals.cloned().unwrap_or(Value::Null);
out.insert(name.to_owned(), as_value_list(&vals));
}
}
}
_ => {}
}
out
}
fn canon_single(v: &Value) -> Map<String, Value> {
let mut out = Map::new();
match v {
Value::Object(m) => out = m.clone(),
Value::Array(a) => {
for e in a {
if let Some(name) = e.get("name").and_then(Value::as_str) {
let val = e.get("value").cloned().unwrap_or(Value::Null);
out.insert(name.to_owned(), val);
}
}
}
_ => {}
}
out
}
fn as_value_list(v: &Value) -> Value {
match v {
Value::Array(_) => v.clone(),
other => Value::Array(vec![other.clone()]),
}
}
fn norm(v: &Value, key: Option<&str>) -> Value {
if v.is_null() {
return Value::Null;
}
if let Some(k) = key {
if MULTI.contains(&k) {
let mut out = Map::new();
for (name, vals) in canon_multi(v) {
let listed = match vals {
Value::Array(a) => Value::Array(a.iter().map(|x| norm(x, None)).collect()),
other => norm(&other, None),
};
out.insert(name, listed);
}
return Value::Object(out);
}
if SINGLE.contains(&k) {
let mut out = Map::new();
for (name, val) in canon_single(v) {
out.insert(name, norm(&val, None));
}
return Value::Object(out);
}
}
match v {
Value::Object(m) => {
let mut out = Map::new();
for (k, x) in m {
if !x.is_null() {
out.insert(k.clone(), norm(x, Some(k)));
}
}
Value::Object(out)
}
Value::Array(a) => Value::Array(a.iter().map(|x| norm(x, None)).collect()),
scalar => scalar.clone(),
}
}
fn join(path: &str, seg: &str) -> String {
if path.is_empty() {
seg.to_owned()
} else {
format!("{path}.{seg}")
}
}
fn diffs(a: &Value, b: &Value, path: &str, out: &mut Vec<String>) {
match a {
Value::Object(am) => {
let Value::Object(bm) = b else {
out.push(root_or(path));
return;
};
for (k, v) in am {
let p = join(path, k);
match bm.get(k) {
Some(bv) => diffs(v, bv, &p, out),
None => out.push(p),
}
}
for k in bm.keys() {
if !am.contains_key(k) {
out.push(format!("{} [ADDED]", join(path, k)));
}
}
}
Value::Array(aa) => {
let Value::Array(ba) = b else {
out.push(root_or(path));
return;
};
for (i, v) in aa.iter().enumerate() {
let p = format!("{path}.{i}");
match ba.get(i) {
Some(bv) => diffs(v, bv, &p, out),
None => out.push(p),
}
}
}
scalar => {
if scalar != b {
out.push(root_or(path));
}
}
}
}
fn root_or(path: &str) -> String {
if path.is_empty() {
"<root>".to_owned()
} else {
path.to_owned()
}
}
fn is_all_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn star(p: &str) -> String {
p.split('.')
.map(|s| if is_all_digits(s) { "*" } else { s })
.collect::<Vec<_>>()
.join(".")
}
fn excuses(g: &str, p: &str) -> bool {
let gs: Vec<&str> = g.split('.').collect();
let ps: Vec<&str> = p.split('.').collect();
if gs.len() > ps.len() {
return false;
}
gs.iter().zip(ps.iter()).all(|(gseg, pseg)| {
gseg == pseg || (*gseg == "*" && is_all_digits(pseg))
})
}
fn is_excused(path: &str, entries: &[String]) -> bool {
entries.iter().any(|g| excuses(g, path))
}
fn expectations_dir() -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../test-fixtures/expectations"
))
}
fn load_fixtures() -> Vec<(String, Value)> {
let dir = expectations_dir();
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read fixtures dir {}: {e}", dir.display()))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.extension().map(|x| x == "json").unwrap_or(false)
&& p.file_name().and_then(|n| n.to_str()) != Some("known-gaps.json")
})
.collect();
files.sort();
assert!(
!files.is_empty(),
"no fixtures found under {}",
dir.display()
);
files
.into_iter()
.map(|p| {
let name = p
.file_name()
.and_then(|n| n.to_str())
.expect("fixture file name")
.to_owned();
let text = std::fs::read_to_string(&p)
.unwrap_or_else(|e| panic!("cannot read fixture {name}: {e}"));
let value: Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("fixture {name} is not valid JSON: {e}"));
(name, value)
})
.collect()
}
fn load_gaps() -> Vec<String> {
let path = match std::env::var("FIDELITY_KNOWN_GAPS") {
Ok(p) if !p.is_empty() => PathBuf::from(p),
_ => expectations_dir().join("known-gaps.json"),
};
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read known-gaps manifest {}: {e}", path.display()));
let manifest: Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("known-gaps manifest {} is not valid JSON: {e}", path.display()));
manifest
.get("rust")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
const UNREPRESENTABLE: &str = "<unrepresentable-expectation>";
fn round_trip(input: &Value) -> Option<Value> {
serde_json::from_value::<Expectation>(input.clone())
.ok()
.map(|model| serde_json::to_value(&model).expect("Expectation serializes to JSON"))
}
fn all_diffs() -> Vec<(String, Vec<String>)> {
load_fixtures()
.into_iter()
.map(|(name, input)| match round_trip(&input) {
None => (name, vec![UNREPRESENTABLE.to_string()]),
Some(output) => {
let mut raw = Vec::new();
diffs(&norm(&input, None), &norm(&output, None), "", &mut raw);
let paths = raw
.into_iter()
.map(|d| d.replace(" [ADDED]", ""))
.collect();
(name, paths)
}
})
.collect()
}
fn discover_mode() -> bool {
std::env::var("FIDELITY_DISCOVER").map(|v| !v.is_empty()).unwrap_or(false)
}
#[test]
fn round_trip_fidelity() {
let per_fixture = all_diffs();
if discover_mode() {
let mut union: BTreeSet<String> = BTreeSet::new();
for (_, paths) in &per_fixture {
for p in paths {
union.insert(star(p));
}
}
let list: Vec<&String> = union.iter().collect();
println!(
"FIDELITY_DISCOVER rust gaps:\n{}",
serde_json::to_string_pretty(&list).expect("serialize discovered gaps")
);
return;
}
let gaps = load_gaps();
let mut failures: Vec<String> = Vec::new();
for (name, paths) in &per_fixture {
let unexcused: Vec<&String> =
paths.iter().filter(|p| !is_excused(p, &gaps)).collect();
if !unexcused.is_empty() {
let mut sorted: Vec<&String> = unexcused;
sorted.sort();
failures.push(format!(
" {name}:\n{}",
sorted
.iter()
.map(|p| format!(" - {p}"))
.collect::<Vec<_>>()
.join("\n")
));
}
}
assert!(
failures.is_empty(),
"round-trip fidelity failures (unexcused dropped/mutated paths):\n{}",
failures.join("\n")
);
}
#[test]
fn known_gaps_ratchet() {
if discover_mode() {
return;
}
let gaps = load_gaps();
let per_fixture = all_diffs();
let all_paths: Vec<&String> =
per_fixture.iter().flat_map(|(_, paths)| paths.iter()).collect();
let stale: Vec<&String> = gaps
.iter()
.filter(|g| !all_paths.iter().any(|p| excuses(g, p)))
.collect();
assert!(
stale.is_empty(),
"stale known-gaps `rust` entries that no longer excuse any diff (remove them):\n{}",
stale
.iter()
.map(|g| format!(" - {g}"))
.collect::<Vec<_>>()
.join("\n")
);
}