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.
358///
359/// Lines unique to `expected` are prefixed with `-`. Lines unique to
360/// `actual` are prefixed with `+`. Common lines are prefixed with ` `.
361/// Implementation is naive (LCS-free); fine for short snapshots.
362fn 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    // Serialize tests that touch DEV_FIXTURES_UPDATE_GOLDEN. Without
405    // this guard, parallel tests race on the env var.
406    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        // Make sure update mode is off for this test.
432        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        // Different lengths: takes the prefix length.
512        assert_eq!(first_diff_offset(&[1, 2], &[1, 2, 3]), 2);
513    }
514}