use std::fs;
use std::path::PathBuf;
fn goldens_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("goldens")
}
fn validate_name(name: &str) {
assert!(
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'),
"snapshot name {name:?} must be non-empty [a-z0-9_-] (it becomes a filename)"
);
}
pub fn assert_snapshot(name: &str, actual: &str) {
validate_name(name);
let path = goldens_dir().join(format!("{name}.txt"));
let update = std::env::var("UPDATE_GOLDENS")
.map(|v| v == "1")
.unwrap_or(false);
if update {
fs::create_dir_all(path.parent().expect("goldens dir has a parent"))
.expect("create goldens dir");
fs::write(&path, actual).expect("write golden");
return;
}
let expected = match fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => panic!(
"golden {} does not exist.\n\
Run with UPDATE_GOLDENS=1 to create it, then REVIEW the file:\n {}\n\
--- actual ---\n{}",
name,
path.display(),
actual
),
};
if expected != actual {
panic!(
"snapshot mismatch for {name} ({}):\n{}\n\
Run with UPDATE_GOLDENS=1 if the change is intended.",
path.display(),
diff(&expected, actual)
);
}
}
fn diff(expected: &str, actual: &str) -> String {
let e: Vec<&str> = expected.lines().collect();
let a: Vec<&str> = actual.lines().collect();
let mut start = 0;
while start < e.len() && start < a.len() && e[start] == a[start] {
start += 1;
}
let mut e_end = e.len();
let mut a_end = a.len();
while e_end > start && a_end > start && e[e_end - 1] == a[a_end - 1] {
e_end -= 1;
a_end -= 1;
}
if start >= e_end && start >= a_end {
return format!(
" (line content equal; byte lengths differ: expected {} vs actual {} — \
trailing whitespace or final newline)\n",
expected.len(),
actual.len()
);
}
let mut out = String::new();
if start > 0 {
out.push_str(&format!(" ... {start} matching line(s) ...\n"));
}
for line in &e[start..e_end] {
out.push_str(&format!("- {line}\n"));
}
for line in &a[start..a_end] {
out.push_str(&format!("+ {line}\n"));
}
let tail = e.len() - e_end;
if tail > 0 {
out.push_str(&format!(" ... {tail} matching line(s) ...\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_trims_common_context() {
let d = diff("a\nb\nc\nd\n", "a\nX\nc\nd\n");
assert!(d.contains("- b"));
assert!(d.contains("+ X"));
assert!(d.contains("1 matching line(s)"));
assert!(d.contains("2 matching line(s)"));
}
#[test]
fn diff_detects_whitespace_only_change() {
let d = diff("a\n", "a");
assert!(d.contains("byte lengths differ"));
}
#[test]
#[should_panic(expected = "snapshot name")]
fn rejects_path_escaping_names() {
assert_snapshot("../evil", "x");
}
}