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 {
369 let exp_lines: Vec<&str> = expected.lines().collect();
370 let act_lines: Vec<&str> = actual.lines().collect();
371 let n = exp_lines.len();
372 let m = act_lines.len();
373
374 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
377 for i in 0..n {
378 for j in 0..m {
379 lcs[i + 1][j + 1] = if exp_lines[i] == act_lines[j] {
380 lcs[i][j] + 1
381 } else {
382 lcs[i][j + 1].max(lcs[i + 1][j])
383 };
384 }
385 }
386
387 let mut ops: Vec<(char, &str)> = Vec::new();
389 let (mut i, mut j) = (n, m);
390 while i > 0 || j > 0 {
391 if i > 0 && j > 0 && exp_lines[i - 1] == act_lines[j - 1] {
392 ops.push((' ', exp_lines[i - 1]));
393 i -= 1;
394 j -= 1;
395 } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
396 ops.push(('+', act_lines[j - 1]));
397 j -= 1;
398 } else if i > 0 {
399 ops.push(('-', exp_lines[i - 1]));
400 i -= 1;
401 } else {
402 break;
404 }
405 }
406 ops.reverse();
407
408 let mut out = String::new();
409 for (sign, line) in ops {
410 out.push(sign);
411 out.push_str(line);
412 out.push('\n');
413 }
414 out
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use dev_report::Verdict;
421 use std::sync::Mutex;
422
423 static ENV_GUARD: Mutex<()> = Mutex::new(());
426
427 #[test]
428 fn first_run_creates_snapshot_and_skips() {
429 let dir = tempfile::tempdir().unwrap();
430 let path = dir.path().join("snap.txt");
431 let g = Golden::new(&path);
432 let c = g.compare("greet", "hello\n");
433 assert_eq!(c.verdict, Verdict::Skip);
434 assert!(c.has_tag("created"));
435 assert_eq!(fs::read_to_string(&path).unwrap(), "hello\n");
436 }
437
438 #[test]
439 fn matching_snapshot_passes() {
440 let dir = tempfile::tempdir().unwrap();
441 let path = dir.path().join("snap.txt");
442 fs::write(&path, "hello\n").unwrap();
443 let c = Golden::new(&path).compare("greet", "hello\n");
444 assert_eq!(c.verdict, Verdict::Pass);
445 }
446
447 #[test]
448 fn mismatching_snapshot_fails_with_diff() {
449 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
450 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
452 let dir = tempfile::tempdir().unwrap();
453 let path = dir.path().join("snap.txt");
454 fs::write(&path, "hello\nworld\n").unwrap();
455 let c = Golden::new(&path).compare("greet", "hello\nuniverse\n");
456 assert_eq!(c.verdict, Verdict::Fail);
457 assert!(c.has_tag("regression"));
458 let detail = c.detail.as_deref().unwrap();
459 assert!(detail.contains("-world"));
460 assert!(detail.contains("+universe"));
461 }
462
463 #[test]
464 fn update_mode_overwrites_snapshot() {
465 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
466 let dir = tempfile::tempdir().unwrap();
467 let path = dir.path().join("snap.txt");
468 fs::write(&path, "old\n").unwrap();
469 std::env::set_var("DEV_FIXTURES_UPDATE_GOLDEN", "1");
470 let c = Golden::new(&path).compare("greet", "new\n");
471 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
472 assert_eq!(c.verdict, Verdict::Skip);
473 assert!(c.has_tag("updated"));
474 assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
475 }
476
477 #[test]
478 fn line_diff_marks_added_and_removed() {
479 let d = line_diff("a\nb\nc\n", "a\nx\nc\n");
480 assert!(d.contains(" a"));
481 assert!(d.contains("-b"));
482 assert!(d.contains("+x"));
483 assert!(d.contains(" c"));
484 }
485
486 #[test]
487 fn line_diff_handles_insertion_in_middle() {
488 let d = line_diff("a\nb\nc\n", "a\nx\nb\nc\n");
491 let lines: Vec<&str> = d.lines().collect();
494 assert_eq!(lines.len(), 4);
495 assert_eq!(lines[0], " a");
496 assert_eq!(lines[1], "+x");
497 assert_eq!(lines[2], " b");
498 assert_eq!(lines[3], " c");
499 }
500
501 #[test]
502 fn line_diff_handles_deletion_in_middle() {
503 let d = line_diff("a\nb\nc\nd\n", "a\nc\nd\n");
504 let lines: Vec<&str> = d.lines().collect();
505 assert_eq!(lines.len(), 4);
506 assert_eq!(lines[0], " a");
507 assert_eq!(lines[1], "-b");
508 assert_eq!(lines[2], " c");
509 assert_eq!(lines[3], " d");
510 }
511
512 #[test]
513 fn line_diff_empty_inputs_yield_empty_output() {
514 assert_eq!(line_diff("", ""), "");
515 }
516
517 #[test]
518 fn line_diff_only_additions() {
519 let d = line_diff("", "a\nb\n");
520 let lines: Vec<&str> = d.lines().collect();
521 assert_eq!(lines, vec!["+a", "+b"]);
522 }
523
524 #[test]
525 fn line_diff_only_deletions() {
526 let d = line_diff("a\nb\n", "");
527 let lines: Vec<&str> = d.lines().collect();
528 assert_eq!(lines, vec!["-a", "-b"]);
529 }
530
531 #[test]
532 fn binary_golden_first_run_creates_snapshot() {
533 let dir = tempfile::tempdir().unwrap();
534 let path = dir.path().join("snap.bin");
535 let g = BinaryGolden::new(&path);
536 let c = g.compare("frame", &[1u8, 2, 3, 4]);
537 assert_eq!(c.verdict, Verdict::Skip);
538 assert!(c.has_tag("created"));
539 assert!(c.has_tag("binary"));
540 let written = std::fs::read(&path).unwrap();
541 assert_eq!(written, vec![1u8, 2, 3, 4]);
542 }
543
544 #[test]
545 fn binary_golden_matching_passes() {
546 let dir = tempfile::tempdir().unwrap();
547 let path = dir.path().join("snap.bin");
548 std::fs::write(&path, [1u8, 2, 3]).unwrap();
549 let c = BinaryGolden::new(&path).compare("frame", &[1u8, 2, 3]);
550 assert_eq!(c.verdict, Verdict::Pass);
551 assert!(c.has_tag("binary"));
552 }
553
554 #[test]
555 fn binary_golden_mismatch_fails_with_offset_and_preview() {
556 let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
557 std::env::remove_var("DEV_FIXTURES_UPDATE_GOLDEN");
558 let dir = tempfile::tempdir().unwrap();
559 let path = dir.path().join("snap.bin");
560 std::fs::write(&path, [1u8, 2, 3, 4, 5]).unwrap();
561 let c = BinaryGolden::new(&path).compare("frame", &[1u8, 2, 99, 4, 5]);
562 assert_eq!(c.verdict, Verdict::Fail);
563 assert!(c.has_tag("regression"));
564 let labels: Vec<&str> = c.evidence.iter().map(|e| e.label.as_str()).collect();
565 assert!(labels.contains(&"first_diff_offset"));
566 assert!(labels.contains(&"expected_hex_preview"));
567 assert!(labels.contains(&"actual_hex_preview"));
568 }
569
570 #[test]
571 fn first_diff_offset_handles_equal_and_unequal() {
572 assert_eq!(first_diff_offset(&[1, 2, 3], &[1, 2, 3]), 3);
573 assert_eq!(first_diff_offset(&[1, 2, 3], &[1, 2, 9]), 2);
574 assert_eq!(first_diff_offset(&[], &[]), 0);
575 assert_eq!(first_diff_offset(&[1, 2], &[1, 2, 3]), 2);
577 }
578}