cratefield_testing/tmp.rs
1//! A scratch directory that is removed **on drop**.
2//!
3//! The rule this exists to enforce: a test that removes its directory on
4//! its last line removes it only when it passes. Every assertion that
5//! fires before that line leaves the directory behind — and the tests that
6//! most need a scratch directory are the ones writing key material, so
7//! what a failing run leaks into `/tmp` is a `0600` private key that
8//! nothing will ever clean up.
9//!
10//! `Drop` runs on the unwind, so the cleanup is not conditional on the
11//! result. The repo hand-rolls this rather than depending on `tempfile`
12//! (the same reasoning `crates/cli-acceptance/tests/card_data_guard.rs`
13//! gives), and this is the copy the workspace shares.
14
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17
18/// A directory under the system temp directory, removed when this value
19/// is dropped — including while a panic unwinds.
20///
21/// ```
22/// # use cratefield_testing::TempDir;
23/// let dir = TempDir::new("my-test");
24/// std::fs::write(dir.join("key"), "secret").expect("writes");
25/// let path = dir.path().to_path_buf();
26/// drop(dir);
27/// assert!(!path.exists());
28/// ```
29#[derive(Debug)]
30pub struct TempDir(PathBuf);
31
32impl TempDir {
33 /// Creates a directory named after `tag`, unique to this call.
34 ///
35 /// # Panics
36 ///
37 /// If the directory cannot be created — a test cannot continue
38 /// without it.
39 #[must_use]
40 pub fn new(tag: &str) -> Self {
41 // A counter as well as the clock: `as_nanos` is not
42 // nanosecond-*resolution* on macOS, and tests build these in
43 // parallel, so two can land in the same tick and then share a
44 // directory — which reads as a mystifying failure in the full
45 // workspace run and passes when the file is run alone.
46 static NEXT: AtomicU64 = AtomicU64::new(0);
47 let dir = std::env::temp_dir().join(format!(
48 "cratefield-{tag}-{}-{}-{}",
49 std::process::id(),
50 std::time::SystemTime::now()
51 .duration_since(std::time::UNIX_EPOCH)
52 .expect("the clock is after the epoch")
53 .as_nanos(),
54 NEXT.fetch_add(1, Ordering::Relaxed),
55 ));
56 std::fs::create_dir_all(&dir).expect("a scratch directory");
57 Self(dir)
58 }
59
60 /// The directory itself.
61 #[must_use]
62 pub fn path(&self) -> &Path {
63 &self.0
64 }
65
66 /// A path inside it. The file need not exist.
67 #[must_use]
68 pub fn join(&self, name: &str) -> PathBuf {
69 self.0.join(name)
70 }
71}
72
73impl Drop for TempDir {
74 fn drop(&mut self) {
75 // Best effort: a test that has already failed must not fail
76 // again, differently, in its own cleanup.
77 let _ = std::fs::remove_dir_all(&self.0);
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn the_directory_goes_away_even_when_the_test_holding_it_panics() {
87 // The whole point: cleanup on the last line of a test is cleanup
88 // that never runs on the run that needed it.
89 let dir = TempDir::new("panics");
90 let path = dir.path().to_path_buf();
91 std::fs::write(dir.join("vapid.key"), "a private key").expect("writes");
92 assert!(path.exists(), "the directory was created");
93
94 // The directory is *moved* into the closure, so the only thing
95 // that can remove it is its `Drop` running on the unwind.
96 let result = std::panic::catch_unwind(move || {
97 let _held = dir;
98 panic!("an assertion fires here");
99 });
100
101 assert!(result.is_err(), "the panic is the scenario");
102 assert!(
103 !path.exists(),
104 "the unwind left {} behind, private key and all",
105 path.display()
106 );
107 }
108
109 #[test]
110 fn two_directories_taken_in_the_same_tick_are_not_the_same_directory() {
111 let first = TempDir::new("same-tick");
112 let second = TempDir::new("same-tick");
113 assert_ne!(first.path(), second.path());
114 }
115}