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}