1use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15use dev_report::{CheckResult, Evidence, Severity};
16
17pub struct Golden {
32 path: PathBuf,
33}
34
35impl Golden {
36 pub fn new(path: impl Into<PathBuf>) -> Self {
40 Self { path: path.into() }
41 }
42
43 pub fn compare(&self, name: impl AsRef<str>, actual: &str) -> CheckResult {
56 let name = format!("fixtures::golden::{}", name.as_ref());
57 let evidence_base = vec![Evidence::numeric("actual_bytes", actual.len() as f64)];
58
59 if !self.path.exists() {
60 if let Err(e) = self.write_snapshot(actual) {
62 let mut c = CheckResult::fail(name, Severity::Error)
63 .with_detail(format!("could not create snapshot: {}", e));
64 c.tags = vec![
65 "fixtures".to_string(),
66 "golden".to_string(),
67 "io_error".to_string(),
68 "regression".to_string(),
69 ];
70 c.evidence = evidence_base;
71 return c;
72 }
73 let mut c = CheckResult::skip(name)
74 .with_detail(format!("created snapshot at {}", self.path.display()));
75 c.tags = vec![
76 "fixtures".to_string(),
77 "golden".to_string(),
78 "created".to_string(),
79 ];
80 c.evidence = evidence_base;
81 return c;
82 }
83
84 let expected = match fs::read_to_string(&self.path) {
85 Ok(s) => s,
86 Err(e) => {
87 let mut c = CheckResult::fail(name, Severity::Error)
88 .with_detail(format!("could not read snapshot: {}", e));
89 c.tags = vec![
90 "fixtures".to_string(),
91 "golden".to_string(),
92 "io_error".to_string(),
93 "regression".to_string(),
94 ];
95 c.evidence = evidence_base;
96 return c;
97 }
98 };
99
100 if actual == expected {
101 let mut c = CheckResult::pass(name).with_detail("snapshot matched");
102 c.tags = vec!["fixtures".to_string(), "golden".to_string()];
103 c.evidence = vec![
104 Evidence::numeric("actual_bytes", actual.len() as f64),
105 Evidence::numeric("expected_bytes", expected.len() as f64),
106 ];
107 return c;
108 }
109
110 if update_mode_enabled() {
112 if let Err(e) = self.write_snapshot(actual) {
113 let mut c = CheckResult::fail(name, Severity::Error)
114 .with_detail(format!("could not update snapshot: {}", e));
115 c.tags = vec![
116 "fixtures".to_string(),
117 "golden".to_string(),
118 "io_error".to_string(),
119 "regression".to_string(),
120 ];
121 c.evidence = evidence_base;
122 return c;
123 }
124 let mut c = CheckResult::skip(name)
125 .with_detail(format!("updated snapshot at {}", self.path.display()));
126 c.tags = vec![
127 "fixtures".to_string(),
128 "golden".to_string(),
129 "updated".to_string(),
130 ];
131 c.evidence = evidence_base;
132 return c;
133 }
134
135 let diff = line_diff(&expected, actual);
136 let mut c = CheckResult::fail(name, Severity::Error)
137 .with_detail(format!("snapshot mismatch:\n{}", diff));
138 c.tags = vec![
139 "fixtures".to_string(),
140 "golden".to_string(),
141 "regression".to_string(),
142 ];
143 c.evidence = vec![
144 Evidence::numeric("actual_bytes", actual.len() as f64),
145 Evidence::numeric("expected_bytes", expected.len() as f64),
146 Evidence::snippet("expected", expected),
147 Evidence::snippet("actual", actual.to_string()),
148 Evidence::snippet("diff", diff),
149 ];
150 c
151 }
152
153 fn write_snapshot(&self, content: &str) -> io::Result<()> {
154 if let Some(parent) = self.path.parent() {
155 fs::create_dir_all(parent)?;
156 }
157 fs::write(&self.path, content)
158 }
159
160 pub fn path(&self) -> &Path {
162 &self.path
163 }
164}
165
166fn update_mode_enabled() -> bool {
168 std::env::var("DEV_FIXTURES_UPDATE_GOLDEN")
169 .map(|v| !v.is_empty())
170 .unwrap_or(false)
171}
172
173fn line_diff(expected: &str, actual: &str) -> String {
179 let exp_lines: Vec<&str> = expected.lines().collect();
180 let act_lines: Vec<&str> = actual.lines().collect();
181 let mut out = String::new();
182 let max = exp_lines.len().max(act_lines.len());
183 for i in 0..max {
184 match (exp_lines.get(i), act_lines.get(i)) {
185 (Some(e), Some(a)) if e == a => {
186 out.push(' ');
187 out.push_str(e);
188 out.push('\n');
189 }
190 (Some(e), Some(a)) => {
191 out.push('-');
192 out.push_str(e);
193 out.push('\n');
194 out.push('+');
195 out.push_str(a);
196 out.push('\n');
197 }
198 (Some(e), None) => {
199 out.push('-');
200 out.push_str(e);
201 out.push('\n');
202 }
203 (None, Some(a)) => {
204 out.push('+');
205 out.push_str(a);
206 out.push('\n');
207 }
208 (None, None) => break,
209 }
210 }
211 out
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use dev_report::Verdict;
218 use std::sync::Mutex;
219
220 static ENV_GUARD: Mutex<()> = Mutex::new(());
223
224 #[test]
225 fn first_run_creates_snapshot_and_skips() {
226 let dir = tempfile::tempdir().unwrap();
227 let path = dir.path().join("snap.txt");
228 let g = Golden::new(&path);
229 let c = g.compare("greet", "hello\n");
230 assert_eq!(c.verdict, Verdict::Skip);
231 assert!(c.has_tag("created"));
232 assert_eq!(fs::read_to_string(&path).unwrap(), "hello\n");
233 }
234
235 #[test]
236 fn matching_snapshot_passes() {
237 let dir = tempfile::tempdir().unwrap();
238 let path = dir.path().join("snap.txt");
239 fs::write(&path, "hello\n").unwrap();
240 let c = Golden::new(&path).compare("greet", "hello\n");
241 assert_eq!(c.verdict, Verdict::Pass);
242 }
243
244 #[test]
245 fn mismatching_snapshot_fails_with_diff() {
246 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
247 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
249 let dir = tempfile::tempdir().unwrap();
250 let path = dir.path().join("snap.txt");
251 fs::write(&path, "hello\nworld\n").unwrap();
252 let c = Golden::new(&path).compare("greet", "hello\nuniverse\n");
253 assert_eq!(c.verdict, Verdict::Fail);
254 assert!(c.has_tag("regression"));
255 let detail = c.detail.as_deref().unwrap();
256 assert!(detail.contains("-world"));
257 assert!(detail.contains("+universe"));
258 }
259
260 #[test]
261 fn update_mode_overwrites_snapshot() {
262 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
263 let dir = tempfile::tempdir().unwrap();
264 let path = dir.path().join("snap.txt");
265 fs::write(&path, "old\n").unwrap();
266 std::env::set_var("DEV_FIXTURES_UPDATE_GOLDEN", "1");
267 let c = Golden::new(&path).compare("greet", "new\n");
268 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
269 assert_eq!(c.verdict, Verdict::Skip);
270 assert!(c.has_tag("updated"));
271 assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
272 }
273
274 #[test]
275 fn line_diff_marks_added_and_removed() {
276 let d = line_diff("a\nb\nc\n", "a\nx\nc\n");
277 assert!(d.contains(" a"));
278 assert!(d.contains("-b"));
279 assert!(d.contains("+x"));
280 assert!(d.contains(" c"));
281 }
282}