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
166pub struct BinaryGolden {
191 path: PathBuf,
192}
193
194impl BinaryGolden {
195 pub fn new(path: impl Into<PathBuf>) -> Self {
197 Self { path: path.into() }
198 }
199
200 pub fn path(&self) -> &Path {
202 &self.path
203 }
204
205 pub fn compare(&self, name: impl AsRef<str>, actual: &[u8]) -> CheckResult {
215 let name = format!("fixtures::golden::{}", name.as_ref());
216 let evidence_base = vec![Evidence::numeric("actual_bytes", actual.len() as f64)];
217
218 if !self.path.exists() {
219 if let Err(e) = self.write_snapshot(actual) {
220 let mut c = CheckResult::fail(name, Severity::Error)
221 .with_detail(format!("could not create snapshot: {}", e));
222 c.tags = vec![
223 "fixtures".to_string(),
224 "golden".to_string(),
225 "binary".to_string(),
226 "io_error".to_string(),
227 "regression".to_string(),
228 ];
229 c.evidence = evidence_base;
230 return c;
231 }
232 let mut c = CheckResult::skip(name)
233 .with_detail(format!("created snapshot at {}", self.path.display()));
234 c.tags = vec![
235 "fixtures".to_string(),
236 "golden".to_string(),
237 "binary".to_string(),
238 "created".to_string(),
239 ];
240 c.evidence = evidence_base;
241 return c;
242 }
243
244 let expected = match fs::read(&self.path) {
245 Ok(b) => b,
246 Err(e) => {
247 let mut c = CheckResult::fail(name, Severity::Error)
248 .with_detail(format!("could not read snapshot: {}", e));
249 c.tags = vec![
250 "fixtures".to_string(),
251 "golden".to_string(),
252 "binary".to_string(),
253 "io_error".to_string(),
254 "regression".to_string(),
255 ];
256 c.evidence = evidence_base;
257 return c;
258 }
259 };
260
261 if actual == expected {
262 let mut c = CheckResult::pass(name).with_detail("snapshot matched");
263 c.tags = vec![
264 "fixtures".to_string(),
265 "golden".to_string(),
266 "binary".to_string(),
267 ];
268 c.evidence = vec![
269 Evidence::numeric("actual_bytes", actual.len() as f64),
270 Evidence::numeric("expected_bytes", expected.len() as f64),
271 ];
272 return c;
273 }
274
275 if update_mode_enabled() {
276 if let Err(e) = self.write_snapshot(actual) {
277 let mut c = CheckResult::fail(name, Severity::Error)
278 .with_detail(format!("could not update snapshot: {}", e));
279 c.tags = vec![
280 "fixtures".to_string(),
281 "golden".to_string(),
282 "binary".to_string(),
283 "io_error".to_string(),
284 "regression".to_string(),
285 ];
286 c.evidence = evidence_base;
287 return c;
288 }
289 let mut c = CheckResult::skip(name)
290 .with_detail(format!("updated snapshot at {}", self.path.display()));
291 c.tags = vec![
292 "fixtures".to_string(),
293 "golden".to_string(),
294 "binary".to_string(),
295 "updated".to_string(),
296 ];
297 c.evidence = evidence_base;
298 return c;
299 }
300
301 let first_diff = first_diff_offset(&expected, actual);
302 let preview_expected = hex_preview(&expected, first_diff, 32);
303 let preview_actual = hex_preview(actual, first_diff, 32);
304 let detail = format!(
305 "binary mismatch (expected {} bytes, actual {} bytes, first diff at offset {})",
306 expected.len(),
307 actual.len(),
308 first_diff
309 );
310 let mut c = CheckResult::fail(name, Severity::Error).with_detail(detail);
311 c.tags = vec![
312 "fixtures".to_string(),
313 "golden".to_string(),
314 "binary".to_string(),
315 "regression".to_string(),
316 ];
317 c.evidence = vec![
318 Evidence::numeric("actual_bytes", actual.len() as f64),
319 Evidence::numeric("expected_bytes", expected.len() as f64),
320 Evidence::numeric("first_diff_offset", first_diff as f64),
321 Evidence::snippet("expected_hex_preview", preview_expected),
322 Evidence::snippet("actual_hex_preview", preview_actual),
323 ];
324 c
325 }
326
327 fn write_snapshot(&self, content: &[u8]) -> io::Result<()> {
328 if let Some(parent) = self.path.parent() {
329 fs::create_dir_all(parent)?;
330 }
331 fs::write(&self.path, content)
332 }
333}
334
335fn first_diff_offset(a: &[u8], b: &[u8]) -> usize {
336 a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
337}
338
339fn hex_preview(bytes: &[u8], from: usize, len: usize) -> String {
340 let end = (from + len).min(bytes.len());
341 let slice = &bytes[from..end];
342 let hex: String = slice
343 .iter()
344 .map(|b| format!("{:02x}", b))
345 .collect::<Vec<_>>()
346 .join(" ");
347 format!("offset {}: {}", from, hex)
348}
349
350fn update_mode_enabled() -> bool {
352 std::env::var("DEV_FIXTURES_UPDATE_GOLDEN")
353 .map(|v| !v.is_empty())
354 .unwrap_or(false)
355}
356
357fn line_diff(expected: &str, actual: &str) -> String {
363 let exp_lines: Vec<&str> = expected.lines().collect();
364 let act_lines: Vec<&str> = actual.lines().collect();
365 let mut out = String::new();
366 let max = exp_lines.len().max(act_lines.len());
367 for i in 0..max {
368 match (exp_lines.get(i), act_lines.get(i)) {
369 (Some(e), Some(a)) if e == a => {
370 out.push(' ');
371 out.push_str(e);
372 out.push('\n');
373 }
374 (Some(e), Some(a)) => {
375 out.push('-');
376 out.push_str(e);
377 out.push('\n');
378 out.push('+');
379 out.push_str(a);
380 out.push('\n');
381 }
382 (Some(e), None) => {
383 out.push('-');
384 out.push_str(e);
385 out.push('\n');
386 }
387 (None, Some(a)) => {
388 out.push('+');
389 out.push_str(a);
390 out.push('\n');
391 }
392 (None, None) => break,
393 }
394 }
395 out
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use dev_report::Verdict;
402 use std::sync::Mutex;
403
404 static ENV_GUARD: Mutex<()> = Mutex::new(());
407
408 #[test]
409 fn first_run_creates_snapshot_and_skips() {
410 let dir = tempfile::tempdir().unwrap();
411 let path = dir.path().join("snap.txt");
412 let g = Golden::new(&path);
413 let c = g.compare("greet", "hello\n");
414 assert_eq!(c.verdict, Verdict::Skip);
415 assert!(c.has_tag("created"));
416 assert_eq!(fs::read_to_string(&path).unwrap(), "hello\n");
417 }
418
419 #[test]
420 fn matching_snapshot_passes() {
421 let dir = tempfile::tempdir().unwrap();
422 let path = dir.path().join("snap.txt");
423 fs::write(&path, "hello\n").unwrap();
424 let c = Golden::new(&path).compare("greet", "hello\n");
425 assert_eq!(c.verdict, Verdict::Pass);
426 }
427
428 #[test]
429 fn mismatching_snapshot_fails_with_diff() {
430 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
431 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
433 let dir = tempfile::tempdir().unwrap();
434 let path = dir.path().join("snap.txt");
435 fs::write(&path, "hello\nworld\n").unwrap();
436 let c = Golden::new(&path).compare("greet", "hello\nuniverse\n");
437 assert_eq!(c.verdict, Verdict::Fail);
438 assert!(c.has_tag("regression"));
439 let detail = c.detail.as_deref().unwrap();
440 assert!(detail.contains("-world"));
441 assert!(detail.contains("+universe"));
442 }
443
444 #[test]
445 fn update_mode_overwrites_snapshot() {
446 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
447 let dir = tempfile::tempdir().unwrap();
448 let path = dir.path().join("snap.txt");
449 fs::write(&path, "old\n").unwrap();
450 std::env::set_var("DEV_FIXTURES_UPDATE_GOLDEN", "1");
451 let c = Golden::new(&path).compare("greet", "new\n");
452 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
453 assert_eq!(c.verdict, Verdict::Skip);
454 assert!(c.has_tag("updated"));
455 assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
456 }
457
458 #[test]
459 fn line_diff_marks_added_and_removed() {
460 let d = line_diff("a\nb\nc\n", "a\nx\nc\n");
461 assert!(d.contains(" a"));
462 assert!(d.contains("-b"));
463 assert!(d.contains("+x"));
464 assert!(d.contains(" c"));
465 }
466
467 #[test]
468 fn binary_golden_first_run_creates_snapshot() {
469 let dir = tempfile::tempdir().unwrap();
470 let path = dir.path().join("snap.bin");
471 let g = BinaryGolden::new(&path);
472 let c = g.compare("frame", &[1u8, 2, 3, 4]);
473 assert_eq!(c.verdict, Verdict::Skip);
474 assert!(c.has_tag("created"));
475 assert!(c.has_tag("binary"));
476 let written = std::fs::read(&path).unwrap();
477 assert_eq!(written, vec![1u8, 2, 3, 4]);
478 }
479
480 #[test]
481 fn binary_golden_matching_passes() {
482 let dir = tempfile::tempdir().unwrap();
483 let path = dir.path().join("snap.bin");
484 std::fs::write(&path, [1u8, 2, 3]).unwrap();
485 let c = BinaryGolden::new(&path).compare("frame", &[1u8, 2, 3]);
486 assert_eq!(c.verdict, Verdict::Pass);
487 assert!(c.has_tag("binary"));
488 }
489
490 #[test]
491 fn binary_golden_mismatch_fails_with_offset_and_preview() {
492 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
493 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
494 let dir = tempfile::tempdir().unwrap();
495 let path = dir.path().join("snap.bin");
496 std::fs::write(&path, [1u8, 2, 3, 4, 5]).unwrap();
497 let c = BinaryGolden::new(&path).compare("frame", &[1u8, 2, 99, 4, 5]);
498 assert_eq!(c.verdict, Verdict::Fail);
499 assert!(c.has_tag("regression"));
500 let labels: Vec<&str> = c.evidence.iter().map(|e| e.label.as_str()).collect();
501 assert!(labels.contains(&"first_diff_offset"));
502 assert!(labels.contains(&"expected_hex_preview"));
503 assert!(labels.contains(&"actual_hex_preview"));
504 }
505
506 #[test]
507 fn first_diff_offset_handles_equal_and_unequal() {
508 assert_eq!(first_diff_offset(&[1, 2, 3], &[1, 2, 3]), 3);
509 assert_eq!(first_diff_offset(&[1, 2, 3], &[1, 2, 9]), 2);
510 assert_eq!(first_diff_offset(&[], &[]), 0);
511 assert_eq!(first_diff_offset(&[1, 2], &[1, 2, 3]), 2);
513 }
514}