Skip to main content

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}