xerv_core/testing/
snapshot.rs1use serde::{Serialize, de::DeserializeOwned};
7use std::env;
8use std::fs;
9use std::path::PathBuf;
10
11fn snapshot_dir() -> PathBuf {
13 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
15 .join("tests")
16 .join("snapshots")
17}
18
19fn snapshot_path(name: &str) -> PathBuf {
21 snapshot_dir().join(format!("{}.snap", name))
22}
23
24fn should_update() -> bool {
26 env::var("UPDATE_SNAPSHOTS")
27 .map(|v| v == "1" || v == "true")
28 .unwrap_or(false)
29}
30
31pub fn assert_snapshot<T>(name: &str, value: &T)
57where
58 T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
59{
60 let path = snapshot_path(name);
61 let actual_json = serde_json::to_string_pretty(value).expect("Failed to serialize value");
62
63 if should_update() || !path.exists() {
64 if let Some(parent) = path.parent() {
66 fs::create_dir_all(parent).expect("Failed to create snapshot directory");
67 }
68
69 fs::write(&path, &actual_json).expect("Failed to write snapshot");
71
72 if !should_update() {
73 println!("Created new snapshot: {}", path.display());
74 } else {
75 println!("Updated snapshot: {}", path.display());
76 }
77 return;
78 }
79
80 let expected_json = fs::read_to_string(&path).expect("Failed to read snapshot");
82
83 if actual_json != expected_json {
84 let expected: T =
85 serde_json::from_str(&expected_json).expect("Failed to deserialize snapshot");
86
87 if value == &expected {
88 return;
91 }
92
93 let diff = generate_diff(&expected_json, &actual_json);
95
96 panic!(
97 "Snapshot mismatch for '{}'!\n\n\
98 Run with UPDATE_SNAPSHOTS=1 to update.\n\n\
99 Diff:\n{}\n\n\
100 Snapshot file: {}",
101 name,
102 diff,
103 path.display()
104 );
105 }
106}
107
108fn generate_diff(expected: &str, actual: &str) -> String {
110 let expected_lines: Vec<&str> = expected.lines().collect();
111 let actual_lines: Vec<&str> = actual.lines().collect();
112
113 let mut diff = String::new();
114 let max_lines = expected_lines.len().max(actual_lines.len());
115
116 for i in 0..max_lines {
117 let exp = expected_lines.get(i).copied().unwrap_or("");
118 let act = actual_lines.get(i).copied().unwrap_or("");
119
120 if exp == act {
121 diff.push_str(&format!(" {}\n", exp));
122 } else {
123 if !exp.is_empty() {
124 diff.push_str(&format!("- {}\n", exp));
125 }
126 if !act.is_empty() {
127 diff.push_str(&format!("+ {}\n", act));
128 }
129 }
130 }
131
132 diff
133}
134
135pub fn assert_json_snapshot(name: &str, value: &serde_json::Value) {
139 assert_snapshot(name, value);
140}
141
142pub fn assert_string_snapshot(name: &str, value: &str) {
146 let path = snapshot_path(name);
147
148 if should_update() || !path.exists() {
149 if let Some(parent) = path.parent() {
150 fs::create_dir_all(parent).expect("Failed to create snapshot directory");
151 }
152 fs::write(&path, value).expect("Failed to write snapshot");
153 return;
154 }
155
156 let expected = fs::read_to_string(&path).expect("Failed to read snapshot");
157
158 if value != expected {
159 let diff = generate_diff(&expected, value);
160 panic!(
161 "String snapshot mismatch for '{}'!\n\n\
162 Run with UPDATE_SNAPSHOTS=1 to update.\n\n\
163 Diff:\n{}\n\n\
164 Snapshot file: {}",
165 name,
166 diff,
167 path.display()
168 );
169 }
170}
171
172#[macro_export]
186macro_rules! assert_inline_snapshot {
187 ($actual:expr, $expected:expr) => {{
188 let actual = serde_json::to_string_pretty(&$actual).expect("Failed to serialize");
189 let expected = $expected;
190 if actual != expected {
191 panic!(
192 "Inline snapshot mismatch!\n\nExpected:\n{}\n\nActual:\n{}",
193 expected, actual
194 );
195 }
196 }};
197}
198
199pub fn redact_json(value: &serde_json::Value, keys_to_redact: &[&str]) -> serde_json::Value {
219 match value {
220 serde_json::Value::Object(map) => {
221 let mut new_map = serde_json::Map::new();
222 for (k, v) in map {
223 if keys_to_redact.contains(&k.as_str()) {
224 new_map.insert(
225 k.clone(),
226 serde_json::Value::String("[REDACTED]".to_string()),
227 );
228 } else {
229 new_map.insert(k.clone(), redact_json(v, keys_to_redact));
230 }
231 }
232 serde_json::Value::Object(new_map)
233 }
234 serde_json::Value::Array(arr) => {
235 serde_json::Value::Array(arr.iter().map(|v| redact_json(v, keys_to_redact)).collect())
236 }
237 other => other.clone(),
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use serde_json::json;
245
246 #[test]
247 fn test_redact_json() {
248 let input = json!({
249 "public": "visible",
250 "secret": "hidden",
251 "nested": {
252 "timestamp": 12345,
253 "data": "ok"
254 },
255 "array": [
256 {"id": "a", "value": 1},
257 {"id": "b", "value": 2}
258 ]
259 });
260
261 let redacted = redact_json(&input, &["secret", "timestamp", "id"]);
262
263 assert_eq!(redacted["public"], "visible");
264 assert_eq!(redacted["secret"], "[REDACTED]");
265 assert_eq!(redacted["nested"]["timestamp"], "[REDACTED]");
266 assert_eq!(redacted["nested"]["data"], "ok");
267 assert_eq!(redacted["array"][0]["id"], "[REDACTED]");
268 assert_eq!(redacted["array"][0]["value"], 1);
269 }
270
271 #[test]
272 fn test_generate_diff() {
273 let expected = "line1\nline2\nline3";
274 let actual = "line1\nmodified\nline3";
275
276 let diff = generate_diff(expected, actual);
277 assert!(diff.contains("- line2"));
278 assert!(diff.contains("+ modified"));
279 }
280}