use serde_json::{Map, Value};
pub fn shape_of(value: &Value) -> Value {
match value {
Value::Object(map) => {
let shaped: Map<String, Value> = map
.iter()
.map(|(key, val)| (key.clone(), shape_of(val)))
.collect();
Value::Object(shaped)
}
Value::Array(items) => match items.first() {
Some(first) => Value::Array(vec![shape_of(first)]),
None => Value::Array(vec![]),
},
Value::String(_) => Value::String("string".into()),
Value::Number(n) => {
let tag = if n.is_i64() || n.is_u64() {
"integer"
} else {
"number"
};
Value::String(tag.into())
}
Value::Bool(_) => Value::String("boolean".into()),
Value::Null => Value::String("null".into()),
}
}
pub fn diff_shapes(path: &str, baseline: &Value, current: &Value) -> Vec<String> {
match (baseline, current) {
(Value::Object(base), Value::Object(cur)) => diff_objects(path, base, cur),
(Value::Array(base), Value::Array(cur)) => match (base.first(), cur.first()) {
(Some(b), Some(c)) => diff_shapes(&format!("{path}[]"), b, c),
_ => Vec::new(),
},
(Value::String(base_tag), Value::String(cur_tag)) if base_tag != cur_tag => {
vec![format!("{path}: type changed {base_tag} -> {cur_tag}")]
}
(Value::String(_), Value::String(_)) => Vec::new(),
_ => vec![format!(
"{path}: structure changed {} -> {}",
kind(baseline),
kind(current)
)],
}
}
fn diff_objects(path: &str, base: &Map<String, Value>, cur: &Map<String, Value>) -> Vec<String> {
let mut drifts = Vec::new();
for (key, base_val) in base {
let child = join(path, key);
match cur.get(key) {
Some(cur_val) => drifts.extend(diff_shapes(&child, base_val, cur_val)),
None => drifts.push(format!("{child}: key removed")),
}
}
for key in cur.keys() {
if !base.contains_key(key) {
drifts.push(format!("{}: key added", join(path, key)));
}
}
drifts
}
fn kind(value: &Value) -> &'static str {
match value {
Value::Object(_) => "object",
Value::Array(_) => "array",
_ => "scalar",
}
}
fn join(path: &str, key: &str) -> String {
if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn shape_discards_scalar_values_but_keeps_keys_and_types() {
let response = json!({
"difficulty": 2656,
"average_block_time": 19.5,
"network_name": "mainnet",
"initialized": true,
"node_id": null
});
assert_eq!(
shape_of(&response),
json!({
"difficulty": "integer",
"average_block_time": "number",
"network_name": "string",
"initialized": "boolean",
"node_id": "null"
})
);
}
#[test]
fn shape_samples_array_element() {
let response = json!({ "coin_records": [{ "amount": 100 }, { "amount": 200 }] });
assert_eq!(
shape_of(&response),
json!({ "coin_records": [{ "amount": "integer" }] })
);
}
#[test]
fn identical_shapes_show_no_drift() {
let a = shape_of(&json!({ "blockchain_state": { "difficulty": 100 } }));
let b = shape_of(&json!({ "blockchain_state": { "difficulty": 999 } }));
assert!(diff_shapes("get_blockchain_state", &a, &b).is_empty());
}
#[test]
fn detects_removed_key() {
let base = shape_of(&json!({ "a": 1, "b": 2 }));
let cur = shape_of(&json!({ "a": 1 }));
let drift = diff_shapes("ep", &base, &cur);
assert_eq!(drift, vec!["ep.b: key removed"]);
}
#[test]
fn detects_added_key() {
let base = shape_of(&json!({ "a": 1 }));
let cur = shape_of(&json!({ "a": 1, "c": 3 }));
let drift = diff_shapes("ep", &base, &cur);
assert_eq!(drift, vec!["ep.c: key added"]);
}
#[test]
fn detects_type_flip() {
let base = shape_of(&json!({ "amount": 100 }));
let cur = shape_of(&json!({ "amount": "100" }));
let drift = diff_shapes("ep", &base, &cur);
assert_eq!(drift, vec!["ep.amount: type changed integer -> string"]);
}
#[test]
fn detects_structure_change() {
let base = shape_of(&json!({ "x": { "y": 1 } }));
let cur = shape_of(&json!({ "x": 1 }));
let drift = diff_shapes("ep", &base, &cur);
assert_eq!(drift, vec!["ep.x: structure changed object -> scalar"]);
}
#[test]
fn recurses_into_arrays() {
let base = shape_of(&json!({ "items": [{ "amount": 1 }] }));
let cur = shape_of(&json!({ "items": [{ "amount": "1" }] }));
let drift = diff_shapes("ep", &base, &cur);
assert_eq!(
drift,
vec!["ep.items[].amount: type changed integer -> string"]
);
}
#[test]
fn empty_array_is_not_drift() {
let base = shape_of(&json!({ "items": [{ "amount": 1 }] }));
let cur = shape_of(&json!({ "items": [] }));
assert!(diff_shapes("ep", &base, &cur).is_empty());
}
#[test]
fn mutated_snapshot_is_detected() {
let live = shape_of(&json!({
"get_network_info": { "network_name": "mainnet", "network_prefix": "xch", "success": true }
}));
let mut mutated = live.clone();
let obj = mutated["get_network_info"].as_object_mut().unwrap();
let tag = obj.remove("network_prefix").unwrap();
obj.insert("address_prefix".into(), tag);
let drift = diff_shapes("", &live, &mutated);
assert!(!drift.is_empty());
assert!(drift
.iter()
.any(|d| d.contains("network_prefix: key removed")));
assert!(drift
.iter()
.any(|d| d.contains("address_prefix: key added")));
}
}