Skip to main content

test_better_snapshot/
inline.rs

1//! Inline snapshots: the snapshot literal lives in the test source itself.
2//!
3//! A proc macro cannot rewrite the file it expands, so the mechanism is split
4//! in two, mirroring `insta`:
5//!
6//! - at **runtime**, `check!(value).matches_inline_snapshot(r#"..."#)` compares
7//!   the value against the literal. On a match it passes. On a mismatch with
8//!   `UPDATE_SNAPSHOTS` unset it fails like any assertion; with
9//!   `UPDATE_SNAPSHOTS=1` it records a *pending patch* (the source file, the
10//!   call-site line and column, and the new value) under
11//!   `target/test-better-pending/` and passes;
12//! - the **`test-better-accept` companion binary** (built with the `accept`
13//!   feature) reads those pending patches and rewrites the literals in the
14//!   source files.
15//!
16//! This module is the runtime half: literal normalization, the comparison, and
17//! writing pending patches. It is `std`-only. The accept binary is in
18//! `src/bin/test-better-accept.rs`.
19
20use std::fmt;
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicU64, Ordering};
24
25use crate::SnapshotMode;
26
27/// Where an inline-snapshot call sits in the source: enough for the
28/// `test-better-accept` binary to find the literal and rewrite it.
29///
30/// Built from `std::panic::Location` at the call site, so `file` is whatever
31/// path `rustc` was invoked with (workspace-root-relative in a normal `cargo`
32/// build).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct InlineLocation {
35    /// The source file, as `Location::caller().file()` reports it.
36    pub file: String,
37    /// The 1-based line of the `matches_inline_snapshot` call.
38    pub line: u32,
39    /// The 1-based column of the `matches_inline_snapshot` call.
40    pub column: u32,
41}
42
43/// An inline snapshot did not match and `UPDATE_SNAPSHOTS` was unset.
44///
45/// Carries both sides so `test-better-matchers` can render it as an
46/// expected/actual `TestError` with a diff, exactly like a file-backed
47/// mismatch.
48#[derive(Debug)]
49pub struct InlineSnapshotFailure {
50    /// The normalized inline literal (what the source currently claims).
51    pub expected: String,
52    /// The value under test.
53    pub actual: String,
54}
55
56impl fmt::Display for InlineSnapshotFailure {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "inline snapshot does not match")
59    }
60}
61
62impl std::error::Error for InlineSnapshotFailure {}
63
64/// Normalizes an inline-snapshot literal to the text it actually stands for.
65///
66/// Test source indents the literal for readability, so the raw token is not
67/// the snapshot. Normalization undoes exactly the cosmetic part: it drops a
68/// single leading newline (the `r#"`-then-newline idiom), removes the common
69/// leading indentation shared by every non-blank line, and trims trailing
70/// whitespace. A single-line literal with no leading newline is returned
71/// trimmed of trailing whitespace and otherwise untouched.
72///
73/// ```
74/// use test_better_core::TestResult;
75/// use test_better_matchers::{eq, check};
76/// use test_better_snapshot::normalize_inline_literal;
77///
78/// # fn main() -> TestResult {
79/// let raw = "\n    User { name: \"alice\" }\n";
80/// check!(normalize_inline_literal(raw)).satisfies(eq("User { name: \"alice\" }".to_string()))?;
81/// # Ok(())
82/// # }
83/// ```
84#[must_use]
85pub fn normalize_inline_literal(raw: &str) -> String {
86    // Drop one leading newline (handling a `\r\n` line ending too).
87    let body = raw
88        .strip_prefix("\r\n")
89        .or_else(|| raw.strip_prefix('\n'))
90        .unwrap_or(raw);
91
92    // The common indentation is the minimum leading-whitespace width across
93    // non-blank lines. Leading whitespace is ASCII, so byte width is fine.
94    let indent = body
95        .lines()
96        .filter(|line| !line.trim().is_empty())
97        .map(|line| line.len() - line.trim_start().len())
98        .min()
99        .unwrap_or(0);
100
101    let dedented = body
102        .lines()
103        .map(|line| {
104            if line.len() >= indent {
105                &line[indent..]
106            } else {
107                // A blank line shorter than the common indent: nothing to keep.
108                ""
109            }
110        })
111        .collect::<Vec<_>>()
112        .join("\n");
113
114    dedented.trim_end().to_string()
115}
116
117/// Compares `actual` against the inline-snapshot literal `raw`.
118///
119/// In [`Compare`](SnapshotMode::Compare) mode a mismatch is returned as an
120/// [`InlineSnapshotFailure`]. In [`Update`](SnapshotMode::Update) mode a
121/// mismatch is recorded as a pending patch under `target/test-better-pending/`
122/// (for the `test-better-accept` binary to apply) and `Ok(())` is returned, so
123/// `UPDATE_SNAPSHOTS=1` runs stay green; the literal in the source is corrected
124/// by the accept step, not by the test run.
125///
126/// # Errors
127///
128/// Returns [`InlineSnapshotFailure`] when the value does not match the literal
129/// and the mode is `Compare`. A failure to write the pending patch in `Update`
130/// mode is intentionally swallowed: a missing patch is recoverable (rerun), and
131/// failing the test would be a worse outcome than a dropped patch.
132pub fn assert_inline_snapshot(
133    actual: &str,
134    raw: &str,
135    location: &InlineLocation,
136    mode: SnapshotMode,
137) -> Result<(), InlineSnapshotFailure> {
138    let expected = normalize_inline_literal(raw);
139    // Literal normalization trims trailing whitespace, so a literal can never
140    // carry a trailing newline. Drop a single one from `actual` to match: a
141    // value rendered with a trailing newline should still be snapshot-able,
142    // and the accept step's `format`/`normalize` round-trip drops it anyway.
143    let actual = actual
144        .strip_suffix("\r\n")
145        .or_else(|| actual.strip_suffix('\n'))
146        .unwrap_or(actual);
147    if actual == expected {
148        return Ok(());
149    }
150    match mode {
151        SnapshotMode::Compare => Err(InlineSnapshotFailure {
152            expected,
153            actual: actual.to_string(),
154        }),
155        SnapshotMode::Update => {
156            // Best effort: see the `# Errors` note above.
157            let _ = record_pending_patch(location, actual);
158            Ok(())
159        }
160    }
161}
162
163/// The directory pending inline-snapshot patches are written to and read from:
164/// `target/test-better-pending/` under the workspace root.
165///
166/// The workspace root is found by walking up from the current directory to the
167/// nearest ancestor containing a `Cargo.lock`; `CARGO_TARGET_DIR`, if set,
168/// overrides the `target` location. Both the test process (writing) and the
169/// `test-better-accept` binary (reading) resolve it the same way.
170///
171/// # Errors
172///
173/// Returns an [`std::io::Error`] if the current directory cannot be read or no
174/// `Cargo.lock` is found in any ancestor.
175pub fn pending_patch_dir() -> std::io::Result<PathBuf> {
176    if let Some(target) = std::env::var_os("CARGO_TARGET_DIR") {
177        return Ok(PathBuf::from(target).join("test-better-pending"));
178    }
179    let start = std::env::current_dir()?;
180    let mut dir: &Path = &start;
181    loop {
182        if dir.join("Cargo.lock").is_file() {
183            return Ok(dir.join("target").join("test-better-pending"));
184        }
185        match dir.parent() {
186            Some(parent) => dir = parent,
187            None => {
188                return Err(std::io::Error::new(
189                    std::io::ErrorKind::NotFound,
190                    "no Cargo.lock found in any ancestor of the current directory",
191                ));
192            }
193        }
194    }
195}
196
197/// Counter making each pending-patch file name unique within a process, on top
198/// of the process id, so parallel tests never collide.
199static PATCH_SEQ: AtomicU64 = AtomicU64::new(0);
200
201/// Writes one pending patch as its own file under [`pending_patch_dir`].
202///
203/// A patch is self-contained, so each gets a distinct file and no test ever
204/// has to append to a shared one. The format is line-oriented and needs no
205/// escaping: line 1 is the source file, line 2 is `<line>:<column>`, and
206/// everything from line 3 on is the new snapshot value verbatim (it may span
207/// many lines).
208fn record_pending_patch(location: &InlineLocation, actual: &str) -> std::io::Result<()> {
209    let dir = pending_patch_dir()?;
210    fs::create_dir_all(&dir)?;
211
212    let seq = PATCH_SEQ.fetch_add(1, Ordering::Relaxed);
213    let file_name = format!("{}-{}.patch", std::process::id(), seq);
214    let body = format!(
215        "{}\n{}:{}\n{}",
216        location.file, location.line, location.column, actual
217    );
218    fs::write(dir.join(file_name), body)
219}
220
221/// Parses a pending-patch file body back into its parts: the source file, the
222/// call-site line and column, and the new snapshot value.
223///
224/// This is the inverse of `record_pending_patch`'s format, exposed for the
225/// `test-better-accept` binary.
226///
227/// # Errors
228///
229/// Returns an [`std::io::Error`] with kind `InvalidData` if the body is not at
230/// least two lines or the second line is not `<line>:<column>`.
231pub fn parse_pending_patch(body: &str) -> std::io::Result<(InlineLocation, String)> {
232    let invalid = |msg: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, msg.to_string());
233
234    let mut lines = body.splitn(3, '\n');
235    let file = lines.next().ok_or_else(|| invalid("empty patch file"))?;
236    let position = lines
237        .next()
238        .ok_or_else(|| invalid("patch file is missing its position line"))?;
239    let value = lines.next().unwrap_or("");
240
241    let (line, column) = position
242        .split_once(':')
243        .ok_or_else(|| invalid("position line is not `<line>:<column>`"))?;
244    let line = line
245        .parse()
246        .map_err(|_| invalid("patch line number is not an integer"))?;
247    let column = column
248        .parse()
249        .map_err(|_| invalid("patch column number is not an integer"))?;
250
251    Ok((
252        InlineLocation {
253            file: file.to_string(),
254            line,
255            column,
256        },
257        value.to_string(),
258    ))
259}
260
261#[cfg(test)]
262mod tests {
263    use test_better_core::{OrFail, TestResult};
264    use test_better_matchers::{check, eq, is_true};
265
266    use super::*;
267
268    #[test]
269    fn normalize_drops_leading_newline_and_common_indentation() -> TestResult {
270        let raw = "\n        first\n        second\n    ";
271        check!(normalize_inline_literal(raw)).satisfies(eq("first\nsecond".to_string()))
272    }
273
274    #[test]
275    fn normalize_keeps_relative_indentation() -> TestResult {
276        let raw = "\n    outer\n        inner\n";
277        check!(normalize_inline_literal(raw)).satisfies(eq("outer\n    inner".to_string()))
278    }
279
280    #[test]
281    fn normalize_leaves_a_bare_single_line_literal_alone() -> TestResult {
282        check!(normalize_inline_literal("just this")).satisfies(eq("just this".to_string()))
283    }
284
285    #[test]
286    fn a_matching_literal_passes_in_compare_mode() -> TestResult {
287        let location = InlineLocation {
288            file: "src/x.rs".to_string(),
289            line: 10,
290            column: 5,
291        };
292        assert_inline_snapshot("hello", "\n    hello\n", &location, SnapshotMode::Compare)
293            .or_fail_with("a matching literal must compare equal")
294    }
295
296    #[test]
297    fn a_differing_literal_fails_in_compare_mode_carrying_both_sides() -> TestResult {
298        let location = InlineLocation {
299            file: "src/x.rs".to_string(),
300            line: 10,
301            column: 5,
302        };
303        let failure = assert_inline_snapshot(
304            "actual",
305            "\n    expected\n",
306            &location,
307            SnapshotMode::Compare,
308        )
309        .err()
310        .or_fail_with("a differing literal must fail in compare mode")?;
311        check!(failure.expected).satisfies(eq("expected".to_string()))?;
312        check!(failure.actual).satisfies(eq("actual".to_string()))
313    }
314
315    #[test]
316    fn parse_pending_patch_round_trips_a_recorded_body() -> TestResult {
317        let body = "tests/foo.rs\n42:9\nline one\nline two";
318        let (location, value) = parse_pending_patch(body).or_fail()?;
319        check!(location.file.as_str()).satisfies(eq("tests/foo.rs"))?;
320        check!(location.line).satisfies(eq(42u32))?;
321        check!(location.column).satisfies(eq(9u32))?;
322        check!(value).satisfies(eq("line one\nline two".to_string()))?;
323        // A malformed body is rejected, not silently accepted.
324        check!(parse_pending_patch("only-one-line").is_err()).satisfies(is_true())
325    }
326}