Skip to main content

dev_fixtures/
golden.rs

1//! Golden-file snapshot verification.
2//!
3//! [`Golden`] compares an actual value against a stored expected
4//! value. On mismatch, the result is a [`CheckResult`] with a
5//! line-based diff in `detail` and `EvidenceData::Snippet` evidence.
6//!
7//! Set the `DEV_FIXTURES_UPDATE_GOLDEN` environment variable to any
8//! non-empty value to overwrite the stored snapshot with the actual
9//! value on mismatch — useful for intentional changes.
10
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15use dev_report::{CheckResult, Evidence, Severity};
16
17/// A stored snapshot of an expected text output.
18///
19/// # Example
20///
21/// ```
22/// use dev_fixtures::golden::Golden;
23/// let dir = tempfile::tempdir().unwrap();
24/// let path = dir.path().join("snap.txt");
25/// std::fs::write(&path, "hello\n").unwrap();
26///
27/// let g = Golden::new(&path);
28/// let check = g.compare("greet", "hello\n");
29/// assert!(matches!(check.verdict, dev_report::Verdict::Pass));
30/// ```
31pub struct Golden {
32    path: PathBuf,
33}
34
35impl Golden {
36    /// Build a golden bound to a path on disk. The path may not exist
37    /// yet; on first run the snapshot is created and verdict is `Skip`
38    /// with a `created` tag.
39    pub fn new(path: impl Into<PathBuf>) -> Self {
40        Self { path: path.into() }
41    }
42
43    /// Compare `actual` against the stored snapshot and emit a
44    /// [`CheckResult`] tagged `fixtures` and `golden`.
45    ///
46    /// Verdicts:
47    /// - Snapshot matches -> `Pass`.
48    /// - Snapshot missing -> `Skip` with `created` tag (the snapshot
49    ///   is written to disk for the first time).
50    /// - Snapshot mismatch + update mode -> `Skip` with `updated` tag
51    ///   (the snapshot is overwritten with `actual`).
52    /// - Snapshot mismatch (default) -> `Fail (Error)` with `regression`
53    ///   tag, line-based diff in `detail`, and full actual/expected
54    ///   as snippet evidence.
55    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            // First run: write the snapshot, return Skip.
61            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        // Mismatch.
111        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    /// The path this golden is bound to.
161    pub fn path(&self) -> &Path {
162        &self.path
163    }
164}
165
166/// A binary-content snapshot bound to a path on disk.
167///
168/// Like [`Golden`] but for arbitrary byte content. Useful for verifying
169/// generated images, archives, encoded protocol frames, etc.
170///
171/// On mismatch, the diff is reported as byte-length deltas and a hex
172/// preview of the first differing region — *not* a line-based diff,
173/// because binary content has no meaningful line structure.
174///
175/// Set the `DEV_FIXTURES_UPDATE_GOLDEN` environment variable to update
176/// snapshots on intentional changes.
177///
178/// # Example
179///
180/// ```
181/// use dev_fixtures::golden::BinaryGolden;
182/// let dir = tempfile::tempdir().unwrap();
183/// let path = dir.path().join("snap.bin");
184/// std::fs::write(&path, &[0u8, 1, 2, 3]).unwrap();
185///
186/// let g = BinaryGolden::new(&path);
187/// let check = g.compare("frame", &[0u8, 1, 2, 3]);
188/// assert!(matches!(check.verdict, dev_report::Verdict::Pass));
189/// ```
190pub struct BinaryGolden {
191    path: PathBuf,
192}
193
194impl BinaryGolden {
195    /// Build a binary-golden bound to a path on disk.
196    pub fn new(path: impl Into<PathBuf>) -> Self {
197        Self { path: path.into() }
198    }
199
200    /// The path this golden is bound to.
201    pub fn path(&self) -> &Path {
202        &self.path
203    }
204
205    /// Compare `actual` against the stored snapshot and emit a
206    /// [`CheckResult`] tagged `fixtures` and `golden`.
207    ///
208    /// Verdicts mirror [`Golden::compare`]:
209    /// - Match -> `Pass`.
210    /// - Missing -> `Skip` with `created` tag (snapshot is written).
211    /// - Mismatch + `DEV_FIXTURES_UPDATE_GOLDEN` set -> `Skip` with `updated`.
212    /// - Mismatch -> `Fail (Error)` with `regression` tag, byte-length and
213    ///   first-diff-offset evidence.
214    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
350/// `true` if `DEV_FIXTURES_UPDATE_GOLDEN` is set to a non-empty value.
351fn update_mode_enabled() -> bool {
352    std::env::var("DEV_FIXTURES_UPDATE_GOLDEN")
353        .map(|v| !v.is_empty())
354        .unwrap_or(false)
355}
356
357/// Produce a line-based diff in unified-diff-ish format using LCS.
358///
359/// Lines unique to `expected` are prefixed with `-`. Lines unique to
360/// `actual` are prefixed with `+`. Common lines are prefixed with ` `.
361///
362/// Uses the standard longest-common-subsequence algorithm (O(N*M)
363/// time/space) so insertions and deletions are handled correctly:
364/// inserting one line in the middle produces one `+` line, not a
365/// cascade of edit pairs from the position-aligned naive diff.
366///
367/// Hand-rolled, no external dependencies.
368fn 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    // Build LCS length table.
375    // lcs[i][j] = length of LCS of exp_lines[..i] and act_lines[..j].
376    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    // Walk back to produce the edit script.
388    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            // Should be unreachable; defensive.
403            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    // Serialize tests that touch DEV_FIXTURES_UPDATE_GOLDEN. Without
424    // this guard, parallel tests race on the env var.
425    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        // Make sure update mode is off for this test.
451        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        // LCS-based diff should recognize "a, b, c" inserts "x" between a and b,
489        // not output a cascade of edit pairs from position-aligned diff.
490        let d = line_diff("a\nb\nc\n", "a\nx\nb\nc\n");
491        // Common: a, b, c. Inserted: x. So we expect:
492        // " a", "+x", " b", " c"
493        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        // Different lengths: takes the prefix length.
576        assert_eq!(first_diff_offset(&[1, 2], &[1, 2, 3]), 2);
577    }
578}