Skip to main content

ustar_test_utils/
snapshot_utils.rs

1use similar::{ChangeTag, TextDiff};
2use std::path::Path;
3
4/// Print message only in verbose mode - controlled by insta settings
5macro_rules! verbose_println {
6    ($($arg:tt)*) => {
7        // Only print during test failures or when explicitly verbose
8        // For now, we'll just use this for development and can be controlled via test output
9        #[cfg(debug_assertions)]
10        eprintln!($($arg)*);
11    };
12}
13
14/// Read a snapshot file, automatically decompressing if it's zstd compressed
15/// Returns the FULL file content including headers
16pub fn read_snapshot<P: AsRef<Path>>(path: P) -> Result<String, Box<dyn std::error::Error>> {
17    let path = path.as_ref();
18    let zst_path = path.with_extension("snap.zst");
19
20    // Try to read the zstd compressed version first
21    if zst_path.exists() {
22        let compressed_data = std::fs::read(&zst_path)?;
23        let decompressed = zstd::decode_all(&compressed_data[..])?;
24        Ok(String::from_utf8(decompressed)?)
25    }
26    // Fall back to uncompressed version
27    else if path.exists() {
28        std::fs::read_to_string(path).map_err(Into::into)
29    } else {
30        Err(format!(
31            "Snapshot file not found: {} or {}",
32            path.display(),
33            zst_path.display()
34        )
35        .into())
36    }
37}
38
39/// Ensure all .snap.zst files have corresponding .snap files with identical content
40/// This allows insta to work with uncompressed .snap files while maintaining zstd compressed storage
41fn ensure_snapshots_synchronized(snapshot_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
42    // Read the directory and find all .snap.zst files
43    if !snapshot_dir.exists() {
44        return Ok(()); // No snapshot directory yet
45    }
46
47    for entry in std::fs::read_dir(snapshot_dir)? {
48        let entry = entry?;
49        let path = entry.path();
50
51        // Only process .snap.zst files
52        if path.extension() == Some("zst".as_ref()) && path.to_string_lossy().ends_with(".snap.zst")
53        {
54            // Determine the corresponding .snap file path
55            let snap_path = path.with_extension(""); // Remove .zst extension, leaving .snap
56
57            // Check if we need to decompress
58            let should_decompress = !snap_path.exists() || {
59                // Compare content to ensure they match
60                match (read_snapshot(&snap_path), read_snapshot(&path)) {
61                    (Ok(snap_content), Ok(zst_content)) => snap_content != zst_content,
62                    _ => true, // If we can't read either file, decompress to be safe
63                }
64            };
65
66            if should_decompress {
67                // Decompress .snap.zst to .snap using zstd crate
68                let compressed_data = std::fs::read(&path)?;
69                let decompressed = zstd::decode_all(&compressed_data[..])?;
70                std::fs::write(&snap_path, &decompressed)?;
71                verbose_println!("Synchronized {} -> {}", path.display(), snap_path.display());
72            }
73        }
74    }
75
76    Ok(())
77}
78
79/// Result of a snapshot check - either Ok or a mismatch with details
80#[derive(Debug)]
81pub struct SnapshotMismatch {
82    pub snapshot_name: String,
83    pub diff_path: std::path::PathBuf,
84    pub new_path: std::path::PathBuf,
85}
86
87impl std::fmt::Display for SnapshotMismatch {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(
90            f,
91            "Snapshot mismatch for '{}'\n  Diff: {}\n  New:  {}",
92            self.snapshot_name,
93            self.diff_path.display(),
94            self.new_path.display()
95        )
96    }
97}
98
99/// Check a snapshot without panicking. Returns Ok(()) if the snapshot matches,
100/// or Err(SnapshotMismatch) if there's a mismatch. Creates .snap.new and .snap.diff
101/// files on mismatch for later review/acceptance.
102///
103/// Use this in loops where you want to collect all failures before panicking.
104/// For single-shot tests, use `assert_snapshot_gz` instead.
105///
106/// Looks for snapshots in the calling package's `tests/snapshots/` directory.
107pub fn check_snapshot_gz(snapshot_name: &str, value: &str) -> Result<(), SnapshotMismatch> {
108    let snapshot_dir = get_snapshot_dir();
109    let snapshot_path = snapshot_dir.join(format!("{}.snap", snapshot_name));
110
111    // Ensure all .snap.zst files are decompressed to .snap files for insta to use
112    if let Err(e) = ensure_snapshots_synchronized(&snapshot_dir) {
113        eprintln!("Warning: Failed to synchronize snapshots: {}", e);
114    }
115
116    // Use insta's actual comparison logic by catching panics
117    let mut settings = insta::Settings::clone_current();
118    settings.set_snapshot_path(&snapshot_dir);
119    settings.set_prepend_module_to_snapshot(false);
120
121    let result = std::panic::catch_unwind(|| {
122        settings.bind(|| {
123            insta::assert_snapshot!(snapshot_name, value);
124        });
125    });
126
127    match result {
128        Ok(_) => {
129            // Snapshot matches! No need to do anything - compression handled by acceptance script
130            Ok(())
131        }
132        Err(_) => {
133            // Snapshot mismatch or doesn't exist - insta has already created .snap.new
134            // Always create diff and old files after insta runs, regardless of the failure path
135            create_review_files(&snapshot_path, snapshot_name);
136
137            Err(SnapshotMismatch {
138                snapshot_name: snapshot_name.to_string(),
139                diff_path: snapshot_path.with_extension("snap.diff"),
140                new_path: snapshot_path.with_extension("snap.new"),
141            })
142        }
143    }
144}
145
146/// Create .snap.diff and .snap.old files for review after a snapshot mismatch
147fn create_review_files(snapshot_path: &std::path::Path, snapshot_name: &str) {
148    // With prepend_module_to_snapshot(false), insta uses the snapshot_name directly
149    let new_path = snapshot_path.with_extension("snap.new");
150    let diff_path = snapshot_path.with_extension("snap.diff");
151    let old_path = snapshot_path.with_extension("snap.old");
152
153    // Create .snap.old file (current snapshot decompressed) for easy comparison
154    if !old_path.exists() {
155        if let Ok(expected_content) = read_snapshot(snapshot_path) {
156            if let Err(e) = std::fs::write(&old_path, &expected_content) {
157                eprintln!("Failed to write .snap.old file: {}", e);
158            } else {
159                verbose_println!("Current snapshot saved to: {}", old_path.display());
160            }
161        }
162    }
163
164    // Create diff file if .snap.new exists and we have an existing snapshot
165    if new_path.exists() && !diff_path.exists() {
166        verbose_println!("Creating diff for {}", snapshot_name);
167        if let (Ok(new_content), Ok(expected_content)) = (
168            std::fs::read_to_string(&new_path),
169            read_snapshot(snapshot_path),
170        ) {
171            // Don't strip headers - show diff of full files including metadata
172            let diff_content = create_diff(&expected_content, &new_content, snapshot_name);
173            if let Err(e) = std::fs::write(&diff_path, &diff_content) {
174                eprintln!("Failed to write diff file: {}", e);
175            } else {
176                verbose_println!("Diff written to: {}", diff_path.display());
177            }
178        } else {
179            verbose_println!("Could not read files for diff creation");
180        }
181    }
182}
183
184/// Custom assertion that works with zstd compressed snapshots.
185/// Works like `insta::assert_snapshot!` but reads from `.snap.zst` files.
186/// Panics on mismatch - use `check_snapshot_gz` if you need to collect multiple failures.
187///
188/// The `snapshot_name` should be the full name as it appears in the snapshot file
189/// (e.g., "sas_walker_tests__loop_walker_output" for file
190/// "sas_walker_tests__loop_walker_output.snap.zst")
191pub fn assert_snapshot_gz(snapshot_name: &str, value: &str) {
192    if let Err(mismatch) = check_snapshot_gz(snapshot_name, value) {
193        panic!(
194            "Snapshot mismatch for '{}':\n\nDiff available at: {}\nNew snapshot at: {}\n\nRun ./scripts/insta-zstd.sh to accept the new snapshot.\n",
195            mismatch.snapshot_name,
196            mismatch.diff_path.display(),
197            mismatch.new_path.display(),
198        );
199    }
200}
201
202/// Create a unified diff between expected and actual content using the `similar` crate
203fn create_diff(expected: &str, actual: &str, snapshot_name: &str) -> String {
204    let diff = TextDiff::from_lines(expected, actual);
205    let mut output = String::new();
206
207    output.push_str(&format!("--- {}.snap (expected)\n", snapshot_name));
208    output.push_str(&format!("+++ {}.snap (actual)\n", snapshot_name));
209
210    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
211        if idx > 0 {
212            output.push_str("...\n");
213        }
214
215        for op in group {
216            for change in diff.iter_inline_changes(op) {
217                let sign = match change.tag() {
218                    ChangeTag::Delete => "-",
219                    ChangeTag::Insert => "+",
220                    ChangeTag::Equal => " ",
221                };
222
223                output.push_str(sign);
224                for (emphasized, value) in change.iter_strings_lossy() {
225                    if emphasized {
226                        output.push_str(&format!("«{}»", value));
227                    } else {
228                        output.push_str(&value);
229                    }
230                }
231                if change.missing_newline() {
232                    output.push_str("\n\\ No newline at end of file\n");
233                }
234            }
235        }
236    }
237
238    if output.lines().count() <= 2 {
239        output.push_str("No differences found (this shouldn't happen)\n");
240    }
241
242    output
243}
244
245/// Get the snapshot directory for the current package.
246/// Uses CARGO_MANIFEST_DIR which Cargo sets to the package being tested.
247///
248/// # Panics
249/// Panics with a helpful message if CARGO_MANIFEST_DIR is not set, which
250/// indicates the code is being run outside of Cargo (e.g., direct binary execution).
251fn get_snapshot_dir() -> std::path::PathBuf {
252    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| {
253        panic!(
254            "CARGO_MANIFEST_DIR environment variable not found!\n\
255            \n\
256            This usually means you're running tests outside of Cargo.\n\
257            \n\
258            Solutions:\n\
259            - Run tests with: cargo test\n\
260            - If running from IDE, ensure it uses Cargo to run tests\n\
261            - If running binary directly, set CARGO_MANIFEST_DIR manually\n\
262            \n\
263            CARGO_MANIFEST_DIR should point to the directory containing Cargo.toml"
264        );
265    });
266
267    std::path::Path::new(&manifest_dir)
268        .join("tests")
269        .join("snapshots")
270}