use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use dev_report::{CheckResult, Evidence, Severity};
pub struct Golden {
path: PathBuf,
}
impl Golden {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn compare(&self, name: impl AsRef<str>, actual: &str) -> CheckResult {
let name = format!("fixtures::golden::{}", name.as_ref());
let evidence_base = vec![Evidence::numeric("actual_bytes", actual.len() as f64)];
if !self.path.exists() {
if let Err(e) = self.write_snapshot(actual) {
let mut c = CheckResult::fail(name, Severity::Error)
.with_detail(format!("could not create snapshot: {}", e));
c.tags = vec![
"fixtures".to_string(),
"golden".to_string(),
"io_error".to_string(),
"regression".to_string(),
];
c.evidence = evidence_base;
return c;
}
let mut c = CheckResult::skip(name)
.with_detail(format!("created snapshot at {}", self.path.display()));
c.tags = vec![
"fixtures".to_string(),
"golden".to_string(),
"created".to_string(),
];
c.evidence = evidence_base;
return c;
}
let expected = match fs::read_to_string(&self.path) {
Ok(s) => s,
Err(e) => {
let mut c = CheckResult::fail(name, Severity::Error)
.with_detail(format!("could not read snapshot: {}", e));
c.tags = vec![
"fixtures".to_string(),
"golden".to_string(),
"io_error".to_string(),
"regression".to_string(),
];
c.evidence = evidence_base;
return c;
}
};
if actual == expected {
let mut c = CheckResult::pass(name).with_detail("snapshot matched");
c.tags = vec!["fixtures".to_string(), "golden".to_string()];
c.evidence = vec![
Evidence::numeric("actual_bytes", actual.len() as f64),
Evidence::numeric("expected_bytes", expected.len() as f64),
];
return c;
}
if update_mode_enabled() {
if let Err(e) = self.write_snapshot(actual) {
let mut c = CheckResult::fail(name, Severity::Error)
.with_detail(format!("could not update snapshot: {}", e));
c.tags = vec![
"fixtures".to_string(),
"golden".to_string(),
"io_error".to_string(),
"regression".to_string(),
];
c.evidence = evidence_base;
return c;
}
let mut c = CheckResult::skip(name)
.with_detail(format!("updated snapshot at {}", self.path.display()));
c.tags = vec![
"fixtures".to_string(),
"golden".to_string(),
"updated".to_string(),
];
c.evidence = evidence_base;
return c;
}
let diff = line_diff(&expected, actual);
let mut c = CheckResult::fail(name, Severity::Error)
.with_detail(format!("snapshot mismatch:\n{}", diff));
c.tags = vec![
"fixtures".to_string(),
"golden".to_string(),
"regression".to_string(),
];
c.evidence = vec![
Evidence::numeric("actual_bytes", actual.len() as f64),
Evidence::numeric("expected_bytes", expected.len() as f64),
Evidence::snippet("expected", expected),
Evidence::snippet("actual", actual.to_string()),
Evidence::snippet("diff", diff),
];
c
}
fn write_snapshot(&self, content: &str) -> io::Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&self.path, content)
}
pub fn path(&self) -> &Path {
&self.path
}
}
fn update_mode_enabled() -> bool {
std::env::var("DEV_FIXTURES_UPDATE_GOLDEN")
.map(|v| !v.is_empty())
.unwrap_or(false)
}
fn line_diff(expected: &str, actual: &str) -> String {
let exp_lines: Vec<&str> = expected.lines().collect();
let act_lines: Vec<&str> = actual.lines().collect();
let mut out = String::new();
let max = exp_lines.len().max(act_lines.len());
for i in 0..max {
match (exp_lines.get(i), act_lines.get(i)) {
(Some(e), Some(a)) if e == a => {
out.push(' ');
out.push_str(e);
out.push('\n');
}
(Some(e), Some(a)) => {
out.push('-');
out.push_str(e);
out.push('\n');
out.push('+');
out.push_str(a);
out.push('\n');
}
(Some(e), None) => {
out.push('-');
out.push_str(e);
out.push('\n');
}
(None, Some(a)) => {
out.push('+');
out.push_str(a);
out.push('\n');
}
(None, None) => break,
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use dev_report::Verdict;
use std::sync::Mutex;
static ENV_GUARD: Mutex<()> = Mutex::new(());
#[test]
fn first_run_creates_snapshot_and_skips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("snap.txt");
let g = Golden::new(&path);
let c = g.compare("greet", "hello\n");
assert_eq!(c.verdict, Verdict::Skip);
assert!(c.has_tag("created"));
assert_eq!(fs::read_to_string(&path).unwrap(), "hello\n");
}
#[test]
fn matching_snapshot_passes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("snap.txt");
fs::write(&path, "hello\n").unwrap();
let c = Golden::new(&path).compare("greet", "hello\n");
assert_eq!(c.verdict, Verdict::Pass);
}
#[test]
fn mismatching_snapshot_fails_with_diff() {
let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("snap.txt");
fs::write(&path, "hello\nworld\n").unwrap();
let c = Golden::new(&path).compare("greet", "hello\nuniverse\n");
assert_eq!(c.verdict, Verdict::Fail);
assert!(c.has_tag("regression"));
let detail = c.detail.as_deref().unwrap();
assert!(detail.contains("-world"));
assert!(detail.contains("+universe"));
}
#[test]
fn update_mode_overwrites_snapshot() {
let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("snap.txt");
fs::write(&path, "old\n").unwrap();
std::env::set_var("DEV_FIXTURES_UPDATE_GOLDEN", "1");
let c = Golden::new(&path).compare("greet", "new\n");
std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
assert_eq!(c.verdict, Verdict::Skip);
assert!(c.has_tag("updated"));
assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
}
#[test]
fn line_diff_marks_added_and_removed() {
let d = line_diff("a\nb\nc\n", "a\nx\nc\n");
assert!(d.contains(" a"));
assert!(d.contains("-b"));
assert!(d.contains("+x"));
assert!(d.contains(" c"));
}
}