Skip to main content

mod_tempdir/
lib.rs

1//! # mod-tempdir
2//!
3//! Temporary directory management for Rust. Auto-cleanup on Drop,
4//! collision-resistant naming, cross-platform paths.
5//!
6//! Designed as a `tempfile` replacement at MSRV 1.75. The default
7//! build has zero runtime dependencies outside `std`. An optional
8//! `mod-rand` feature swaps the built-in name mixer for
9//! `mod_rand::tier2::unique_name`, which produces a uniformly
10//! distributed name from a SplitMix + Stafford-finisher pipeline.
11//!
12//! ## Quick example
13//!
14//! ```no_run
15//! use mod_tempdir::TempDir;
16//!
17//! let dir = TempDir::new().unwrap();
18//! // ... use dir.path() to do work ...
19//! // dir is automatically deleted when it goes out of scope
20//! ```
21//!
22//! ## Feature flags
23//!
24//! * `mod-rand` (off by default): use [`mod_rand::tier2::unique_name`][mr-tier2]
25//!   for directory naming. The alphabet is Crockford base32 on both
26//!   paths, so any caller pattern-matching on the directory basename
27//!   keeps working unchanged when the feature is toggled.
28//!
29//! [mr-tier2]: https://docs.rs/mod-rand/latest/mod_rand/tier2/fn.unique_name.html
30//!
31//! To enable in `Cargo.toml`:
32//!
33//! ```toml
34//! mod-tempdir = { version = "0.9", features = ["mod-rand"] }
35//! ```
36//!
37//! ## Cleanup semantics
38//!
39//! `Drop::drop` recursively removes the directory via
40//! [`std::fs::remove_dir_all`]. Failures during cleanup (file in use,
41//! permission denied, network filesystem hiccup) are intentionally
42//! silent: a `Drop` impl must not panic. Use [`TempDir::persist`] to
43//! keep the directory alive past drop if you need to inspect it.
44
45#![cfg_attr(docsrs, feature(doc_cfg))]
46#![warn(missing_docs)]
47#![warn(rust_2018_idioms)]
48
49use std::io;
50use std::path::{Path, PathBuf};
51
52#[cfg(not(feature = "mod-rand"))]
53use std::sync::atomic::{AtomicU64, Ordering};
54#[cfg(not(feature = "mod-rand"))]
55use std::time::{SystemTime, UNIX_EPOCH};
56
57/// A temporary directory that auto-deletes when dropped.
58///
59/// # Example
60///
61/// ```no_run
62/// use mod_tempdir::TempDir;
63///
64/// let dir = TempDir::new().unwrap();
65/// let file_path = dir.path().join("test.txt");
66/// std::fs::write(&file_path, b"hello").unwrap();
67/// // dir and its contents are deleted at end of scope
68/// ```
69#[derive(Debug)]
70pub struct TempDir {
71    path: PathBuf,
72    cleanup_on_drop: bool,
73}
74
75impl TempDir {
76    /// Create a new temporary directory in the system's temp location
77    /// (`/tmp` on Linux/macOS, `%TEMP%` on Windows).
78    ///
79    /// The directory name is a fresh 12-character Crockford base32
80    /// string prefixed with `.tmp-`. With the `mod-rand` feature
81    /// enabled, naming comes from [`mod_rand::tier2::unique_name`][mr-tier2];
82    /// without it, naming comes from an internal process-unique mixer
83    /// (PID + nanosecond clock + atomic counter).
84    ///
85    /// # Errors
86    ///
87    /// Returns the underlying [`io::Error`] from
88    /// [`std::fs::create_dir`] if the directory cannot be created.
89    ///
90    /// [mr-tier2]: https://docs.rs/mod-rand/latest/mod_rand/tier2/fn.unique_name.html
91    pub fn new() -> io::Result<Self> {
92        let name = unique_name(12);
93        let path = std::env::temp_dir().join(format!(".tmp-{name}"));
94        std::fs::create_dir(&path)?;
95        Ok(Self {
96            path,
97            cleanup_on_drop: true,
98        })
99    }
100
101    /// Create a new temporary directory with the given prefix.
102    ///
103    /// The final basename is `{prefix}-{12-char-name}`. The prefix is
104    /// joined verbatim and is the caller's responsibility to sanitize.
105    ///
106    /// # Errors
107    ///
108    /// Returns the underlying [`io::Error`] from
109    /// [`std::fs::create_dir`] if the directory cannot be created.
110    ///
111    /// # Example
112    ///
113    /// ```no_run
114    /// use mod_tempdir::TempDir;
115    ///
116    /// let dir = TempDir::with_prefix("my-app").unwrap();
117    /// assert!(dir
118    ///     .path()
119    ///     .file_name()
120    ///     .unwrap()
121    ///     .to_string_lossy()
122    ///     .starts_with("my-app-"));
123    /// ```
124    pub fn with_prefix(prefix: &str) -> io::Result<Self> {
125        let name = unique_name(12);
126        let path = std::env::temp_dir().join(format!("{prefix}-{name}"));
127        std::fs::create_dir(&path)?;
128        Ok(Self {
129            path,
130            cleanup_on_drop: true,
131        })
132    }
133
134    /// Return the path of this temporary directory.
135    pub fn path(&self) -> &Path {
136        &self.path
137    }
138
139    /// Consume this `TempDir` and return the path, disabling cleanup
140    /// on drop. The directory and its contents will persist.
141    ///
142    /// Use this when you want to inspect contents after a test fails.
143    pub fn persist(mut self) -> PathBuf {
144        self.cleanup_on_drop = false;
145        self.path.clone()
146    }
147
148    /// Return `true` if the directory will be deleted on drop.
149    pub fn cleanup_on_drop(&self) -> bool {
150        self.cleanup_on_drop
151    }
152}
153
154impl Drop for TempDir {
155    fn drop(&mut self) {
156        if self.cleanup_on_drop {
157            // Cleanup is best-effort and must not panic in Drop. Any
158            // filesystem error (file in use, permission denied) is
159            // intentionally swallowed per REPS section 5.
160            let _ = std::fs::remove_dir_all(&self.path);
161        }
162    }
163}
164
165#[cfg(feature = "mod-rand")]
166#[inline]
167fn unique_name(len: usize) -> String {
168    mod_rand::tier2::unique_name(len)
169}
170
171#[cfg(not(feature = "mod-rand"))]
172fn unique_name(len: usize) -> String {
173    static COUNTER: AtomicU64 = AtomicU64::new(0);
174    const ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
175
176    let pid = std::process::id() as u64;
177    let nanos = SystemTime::now()
178        .duration_since(UNIX_EPOCH)
179        .map(|d| d.as_nanos() as u64)
180        .unwrap_or(0);
181    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
182
183    // Placeholder mixing. The `mod-rand` feature replaces this entire
184    // function with `mod_rand::tier2::unique_name`.
185    let mut state = pid.wrapping_mul(0x9E3779B97F4A7C15)
186        ^ nanos.wrapping_mul(0xBF58476D1CE4E5B9)
187        ^ counter.wrapping_mul(0x94D049BB133111EB);
188
189    let mut out = String::with_capacity(len);
190    while out.len() < len {
191        out.push(ALPHABET[(state & 31) as usize] as char);
192        state >>= 5;
193        if state == 0 {
194            state = nanos.wrapping_mul(counter.wrapping_add(1));
195        }
196    }
197    out
198}
199
200/// Internal test hook. **Not part of the stable public API.** This
201/// symbol exists only to let this crate's integration tests exercise
202/// the name generator without paying for a filesystem syscall per
203/// sample. External code must not call it; it may be renamed or
204/// removed in any release, including a patch.
205///
206/// Only compiled when the `mod-rand` feature is enabled, since that is
207/// the only test file that needs it.
208#[cfg(feature = "mod-rand")]
209#[doc(hidden)]
210pub fn __unique_name_for_tests(len: usize) -> String {
211    unique_name(len)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn creates_dir() {
220        let dir = TempDir::new().unwrap();
221        assert!(dir.path().exists());
222        assert!(dir.path().is_dir());
223    }
224
225    #[test]
226    fn auto_cleanup() {
227        let path = {
228            let dir = TempDir::new().unwrap();
229            dir.path().to_path_buf()
230        };
231        // After drop, the directory should no longer exist.
232        assert!(!path.exists());
233    }
234
235    #[test]
236    fn persist_disables_cleanup() {
237        let dir = TempDir::new().unwrap();
238        let path = dir.persist();
239        assert!(path.exists());
240        // Clean up manually since persist was used.
241        std::fs::remove_dir_all(&path).unwrap();
242    }
243
244    #[test]
245    fn with_prefix_works() {
246        let dir = TempDir::with_prefix("test").unwrap();
247        let name = dir.path().file_name().unwrap().to_string_lossy();
248        assert!(name.starts_with("test-"));
249    }
250
251    #[test]
252    fn two_dirs_unique() {
253        let a = TempDir::new().unwrap();
254        let b = TempDir::new().unwrap();
255        assert_ne!(a.path(), b.path());
256    }
257}