1use serde_json::{Map, Value};
16
17pub fn shape_of(value: &Value) -> Value {
25 match value {
26 Value::Object(map) => {
27 let shaped: Map<String, Value> = map
28 .iter()
29 .map(|(key, val)| (key.clone(), shape_of(val)))
30 .collect();
31 Value::Object(shaped)
32 }
33 Value::Array(items) => match items.first() {
37 Some(first) => Value::Array(vec![shape_of(first)]),
38 None => Value::Array(vec![]),
39 },
40 Value::String(_) => Value::String("string".into()),
41 Value::Number(n) => {
42 let tag = if n.is_i64() || n.is_u64() {
43 "integer"
44 } else {
45 "number"
46 };
47 Value::String(tag.into())
48 }
49 Value::Bool(_) => Value::String("boolean".into()),
50 Value::Null => Value::String("null".into()),
51 }
52}
53
54pub fn diff_shapes(path: &str, baseline: &Value, current: &Value) -> Vec<String> {
60 match (baseline, current) {
61 (Value::Object(base), Value::Object(cur)) => diff_objects(path, base, cur),
62 (Value::Array(base), Value::Array(cur)) => match (base.first(), cur.first()) {
63 (Some(b), Some(c)) => diff_shapes(&format!("{path}[]"), b, c),
64 _ => Vec::new(),
67 },
68 (Value::String(base_tag), Value::String(cur_tag)) if base_tag != cur_tag => {
69 vec![format!("{path}: type changed {base_tag} -> {cur_tag}")]
70 }
71 (Value::String(_), Value::String(_)) => Vec::new(),
72 _ => vec![format!(
73 "{path}: structure changed {} -> {}",
74 kind(baseline),
75 kind(current)
76 )],
77 }
78}
79
80fn diff_objects(path: &str, base: &Map<String, Value>, cur: &Map<String, Value>) -> Vec<String> {
83 let mut drifts = Vec::new();
84
85 for (key, base_val) in base {
86 let child = join(path, key);
87 match cur.get(key) {
88 Some(cur_val) => drifts.extend(diff_shapes(&child, base_val, cur_val)),
89 None => drifts.push(format!("{child}: key removed")),
90 }
91 }
92 for key in cur.keys() {
93 if !base.contains_key(key) {
94 drifts.push(format!("{}: key added", join(path, key)));
95 }
96 }
97
98 drifts
99}
100
101fn kind(value: &Value) -> &'static str {
103 match value {
104 Value::Object(_) => "object",
105 Value::Array(_) => "array",
106 _ => "scalar",
107 }
108}
109
110fn join(path: &str, key: &str) -> String {
112 if path.is_empty() {
113 key.to_string()
114 } else {
115 format!("{path}.{key}")
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use serde_json::json;
123
124 #[test]
125 fn shape_discards_scalar_values_but_keeps_keys_and_types() {
126 let response = json!({
127 "difficulty": 2656,
128 "average_block_time": 19.5,
129 "network_name": "mainnet",
130 "initialized": true,
131 "node_id": null
132 });
133 assert_eq!(
134 shape_of(&response),
135 json!({
136 "difficulty": "integer",
137 "average_block_time": "number",
138 "network_name": "string",
139 "initialized": "boolean",
140 "node_id": "null"
141 })
142 );
143 }
144
145 #[test]
146 fn shape_samples_array_element() {
147 let response = json!({ "coin_records": [{ "amount": 100 }, { "amount": 200 }] });
148 assert_eq!(
149 shape_of(&response),
150 json!({ "coin_records": [{ "amount": "integer" }] })
151 );
152 }
153
154 #[test]
155 fn identical_shapes_show_no_drift() {
156 let a = shape_of(&json!({ "blockchain_state": { "difficulty": 100 } }));
157 let b = shape_of(&json!({ "blockchain_state": { "difficulty": 999 } }));
158 assert!(diff_shapes("get_blockchain_state", &a, &b).is_empty());
159 }
160
161 #[test]
162 fn detects_removed_key() {
163 let base = shape_of(&json!({ "a": 1, "b": 2 }));
164 let cur = shape_of(&json!({ "a": 1 }));
165 let drift = diff_shapes("ep", &base, &cur);
166 assert_eq!(drift, vec!["ep.b: key removed"]);
167 }
168
169 #[test]
170 fn detects_added_key() {
171 let base = shape_of(&json!({ "a": 1 }));
172 let cur = shape_of(&json!({ "a": 1, "c": 3 }));
173 let drift = diff_shapes("ep", &base, &cur);
174 assert_eq!(drift, vec!["ep.c: key added"]);
175 }
176
177 #[test]
178 fn detects_type_flip() {
179 let base = shape_of(&json!({ "amount": 100 }));
180 let cur = shape_of(&json!({ "amount": "100" }));
181 let drift = diff_shapes("ep", &base, &cur);
182 assert_eq!(drift, vec!["ep.amount: type changed integer -> string"]);
183 }
184
185 #[test]
186 fn detects_structure_change() {
187 let base = shape_of(&json!({ "x": { "y": 1 } }));
188 let cur = shape_of(&json!({ "x": 1 }));
189 let drift = diff_shapes("ep", &base, &cur);
190 assert_eq!(drift, vec!["ep.x: structure changed object -> scalar"]);
191 }
192
193 #[test]
194 fn recurses_into_arrays() {
195 let base = shape_of(&json!({ "items": [{ "amount": 1 }] }));
196 let cur = shape_of(&json!({ "items": [{ "amount": "1" }] }));
197 let drift = diff_shapes("ep", &base, &cur);
198 assert_eq!(
199 drift,
200 vec!["ep.items[].amount: type changed integer -> string"]
201 );
202 }
203
204 #[test]
205 fn empty_array_is_not_drift() {
206 let base = shape_of(&json!({ "items": [{ "amount": 1 }] }));
207 let cur = shape_of(&json!({ "items": [] }));
208 assert!(diff_shapes("ep", &base, &cur).is_empty());
209 }
210
211 #[test]
214 fn mutated_snapshot_is_detected() {
215 let live = shape_of(&json!({
216 "get_network_info": { "network_name": "mainnet", "network_prefix": "xch", "success": true }
217 }));
218 let mut mutated = live.clone();
219 let obj = mutated["get_network_info"].as_object_mut().unwrap();
221 let tag = obj.remove("network_prefix").unwrap();
222 obj.insert("address_prefix".into(), tag);
223
224 let drift = diff_shapes("", &live, &mutated);
225 assert!(!drift.is_empty());
226 assert!(drift
227 .iter()
228 .any(|d| d.contains("network_prefix: key removed")));
229 assert!(drift
230 .iter()
231 .any(|d| d.contains("address_prefix: key added")));
232 }
233}