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/// `true` if `DEV_FIXTURES_UPDATE_GOLDEN` is set to a non-empty value.
167fn update_mode_enabled() -> bool {
168    std::env::var("DEV_FIXTURES_UPDATE_GOLDEN")
169        .map(|v| !v.is_empty())
170        .unwrap_or(false)
171}
172
173/// Produce a line-based diff in unified-diff-ish format.
174///
175/// Lines unique to `expected` are prefixed with `-`. Lines unique to
176/// `actual` are prefixed with `+`. Common lines are prefixed with ` `.
177/// Implementation is naive (LCS-free); fine for short snapshots.
178fn 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    // Serialize tests that touch DEV_FIXTURES_UPDATE_GOLDEN. Without
221    // this guard, parallel tests race on the env var.
222    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        // Make sure update mode is off for this test.
248        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}