use serde::{Serialize, de::DeserializeOwned};
use std::env;
use std::fs;
use std::path::PathBuf;
fn snapshot_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("snapshots")
}
fn snapshot_path(name: &str) -> PathBuf {
snapshot_dir().join(format!("{}.snap", name))
}
fn should_update() -> bool {
env::var("UPDATE_SNAPSHOTS")
.map(|v| v == "1" || v == "true")
.unwrap_or(false)
}
pub fn assert_snapshot<T>(name: &str, value: &T)
where
T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug,
{
let path = snapshot_path(name);
let actual_json = serde_json::to_string_pretty(value).expect("Failed to serialize value");
if should_update() || !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("Failed to create snapshot directory");
}
fs::write(&path, &actual_json).expect("Failed to write snapshot");
if !should_update() {
println!("Created new snapshot: {}", path.display());
} else {
println!("Updated snapshot: {}", path.display());
}
return;
}
let expected_json = fs::read_to_string(&path).expect("Failed to read snapshot");
if actual_json != expected_json {
let expected: T =
serde_json::from_str(&expected_json).expect("Failed to deserialize snapshot");
if value == &expected {
return;
}
let diff = generate_diff(&expected_json, &actual_json);
panic!(
"Snapshot mismatch for '{}'!\n\n\
Run with UPDATE_SNAPSHOTS=1 to update.\n\n\
Diff:\n{}\n\n\
Snapshot file: {}",
name,
diff,
path.display()
);
}
}
fn generate_diff(expected: &str, actual: &str) -> String {
let expected_lines: Vec<&str> = expected.lines().collect();
let actual_lines: Vec<&str> = actual.lines().collect();
let mut diff = String::new();
let max_lines = expected_lines.len().max(actual_lines.len());
for i in 0..max_lines {
let exp = expected_lines.get(i).copied().unwrap_or("");
let act = actual_lines.get(i).copied().unwrap_or("");
if exp == act {
diff.push_str(&format!(" {}\n", exp));
} else {
if !exp.is_empty() {
diff.push_str(&format!("- {}\n", exp));
}
if !act.is_empty() {
diff.push_str(&format!("+ {}\n", act));
}
}
}
diff
}
pub fn assert_json_snapshot(name: &str, value: &serde_json::Value) {
assert_snapshot(name, value);
}
pub fn assert_string_snapshot(name: &str, value: &str) {
let path = snapshot_path(name);
if should_update() || !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("Failed to create snapshot directory");
}
fs::write(&path, value).expect("Failed to write snapshot");
return;
}
let expected = fs::read_to_string(&path).expect("Failed to read snapshot");
if value != expected {
let diff = generate_diff(&expected, value);
panic!(
"String snapshot mismatch for '{}'!\n\n\
Run with UPDATE_SNAPSHOTS=1 to update.\n\n\
Diff:\n{}\n\n\
Snapshot file: {}",
name,
diff,
path.display()
);
}
}
#[macro_export]
macro_rules! assert_inline_snapshot {
($actual:expr, $expected:expr) => {{
let actual = serde_json::to_string_pretty(&$actual).expect("Failed to serialize");
let expected = $expected;
if actual != expected {
panic!(
"Inline snapshot mismatch!\n\nExpected:\n{}\n\nActual:\n{}",
expected, actual
);
}
}};
}
pub fn redact_json(value: &serde_json::Value, keys_to_redact: &[&str]) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut new_map = serde_json::Map::new();
for (k, v) in map {
if keys_to_redact.contains(&k.as_str()) {
new_map.insert(
k.clone(),
serde_json::Value::String("[REDACTED]".to_string()),
);
} else {
new_map.insert(k.clone(), redact_json(v, keys_to_redact));
}
}
serde_json::Value::Object(new_map)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(|v| redact_json(v, keys_to_redact)).collect())
}
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_redact_json() {
let input = json!({
"public": "visible",
"secret": "hidden",
"nested": {
"timestamp": 12345,
"data": "ok"
},
"array": [
{"id": "a", "value": 1},
{"id": "b", "value": 2}
]
});
let redacted = redact_json(&input, &["secret", "timestamp", "id"]);
assert_eq!(redacted["public"], "visible");
assert_eq!(redacted["secret"], "[REDACTED]");
assert_eq!(redacted["nested"]["timestamp"], "[REDACTED]");
assert_eq!(redacted["nested"]["data"], "ok");
assert_eq!(redacted["array"][0]["id"], "[REDACTED]");
assert_eq!(redacted["array"][0]["value"], 1);
}
#[test]
fn test_generate_diff() {
let expected = "line1\nline2\nline3";
let actual = "line1\nmodified\nline3";
let diff = generate_diff(expected, actual);
assert!(diff.contains("- line2"));
assert!(diff.contains("+ modified"));
}
}