Skip to main content

rskit_testutil/
current_dir.rs

1//! Process-current-directory guard for tests.
2//!
3//! The process working directory is global mutable state:
4//! a test that calls [`std::env::set_current_dir`] mutates a value every other test in the same binary observes.
5//! [`CurrentDirGuard`] makes that safe by
6//!
7//! 1. holding a process-wide lock for its lifetime,
8//!    so concurrent tests in the same binary cannot interleave their directory changes, and
9//! 2. restoring the previous working directory on drop, so a change never leaks into a later test.
10//!
11//! It is the working-directory analogue of the env-var mutex pattern: any test that depends on
12//! or mutates the current directory should hold a guard for the duration of that dependence.
13//!
14//! ```no_run
15//! use rskit_testutil::CurrentDirGuard;
16//!
17//! # fn example(repo_root: &std::path::Path) -> rskit_errors::AppResult<()> {
18//! let _cwd = CurrentDirGuard::change_to(repo_root)?;
19//! // ... code here observes `repo_root` as the working directory ...
20//! // the previous directory is restored when `_cwd` drops.
21//! # Ok(())
22//! # }
23//! ```
24
25use std::path::{Path, PathBuf};
26
27use parking_lot::{ReentrantMutex, ReentrantMutexGuard};
28use rskit_errors::{AppError, AppResult};
29
30/// Serializes every [`CurrentDirGuard`] across threads in the test binary
31/// so concurrent tests cannot interleave process-wide working-directory changes.
32///
33/// The lock is reentrant:
34/// a single thread may hold nested guards (for example a helper that calls [`CurrentDirGuard::pin`] inside a test that already holds a guard) without deadlocking,
35/// while guards on *other* threads still block until every guard on the holding thread is dropped.
36static CWD_LOCK: ReentrantMutex<()> = ReentrantMutex::new(());
37
38/// An RAII guard that pins the process working directory for its lifetime
39/// and restores the previous directory when dropped.
40///
41/// Construct one with [`CurrentDirGuard::change_to`] (change the directory)
42/// or [`CurrentDirGuard::pin`] (hold the current directory without changing it).
43/// While the guard is alive it owns a process-wide reentrant lock,
44/// so guards on other threads block until it drops; nested guards on the same thread are allowed.
45#[derive(Debug)]
46#[must_use = "the working directory is restored when the guard is dropped; bind it to a name"]
47pub struct CurrentDirGuard {
48    previous: PathBuf,
49    _lock: ReentrantMutexGuard<'static, ()>,
50}
51
52impl CurrentDirGuard {
53    /// Acquire the lock and pin the current working directory without changing it, restoring it on drop.
54    ///
55    /// # Errors
56    /// Returns an error if the current working directory cannot be read.
57    pub fn pin() -> AppResult<Self> {
58        let lock = CWD_LOCK.lock();
59        let previous = current_dir()?;
60        Ok(Self {
61            previous,
62            _lock: lock,
63        })
64    }
65
66    /// Acquire the lock, record the current directory, and switch to `dir`.
67    ///
68    /// The previous directory is restored when the guard drops.
69    ///
70    /// # Errors
71    /// Returns an error if the current directory cannot be read
72    /// or if `dir` cannot be set as the working directory.
73    pub fn change_to(dir: impl AsRef<Path>) -> AppResult<Self> {
74        let lock = CWD_LOCK.lock();
75        let previous = current_dir()?;
76        set_current_dir(dir.as_ref())?;
77        Ok(Self {
78            previous,
79            _lock: lock,
80        })
81    }
82
83    /// The working directory captured when the guard was created.
84    #[must_use]
85    pub fn previous(&self) -> &Path {
86        &self.previous
87    }
88}
89
90impl Drop for CurrentDirGuard {
91    fn drop(&mut self) {
92        if let Err(error) = std::env::set_current_dir(&self.previous) {
93            // Restoring the previous directory is the guard's core contract,
94            // so a failure must be loud rather than leaking a broken cwd into later tests.
95            // The lock is still held until this guard finishes dropping,
96            // so no other guard observes the broken directory. Don't panic while already unwinding
97            // — a double panic aborts the process and hides the original failure.
98            assert!(
99                std::thread::panicking(),
100                "CurrentDirGuard failed to restore the working directory to '{}': {error}",
101                self.previous.display()
102            );
103        }
104    }
105}
106
107fn current_dir() -> AppResult<PathBuf> {
108    std::env::current_dir()
109        .map_err(|error| AppError::from(error).context("failed to read current directory"))
110}
111
112fn set_current_dir(dir: &Path) -> AppResult<()> {
113    std::env::set_current_dir(dir).map_err(|error| {
114        AppError::from(error).context(format!(
115            "failed to set current directory to '{}'",
116            dir.display()
117        ))
118    })
119}
120
121#[cfg(test)]
122mod tests {
123    use super::CurrentDirGuard;
124    use crate::TestWorkspace;
125
126    #[test]
127    fn change_to_switches_and_restores_on_drop() {
128        let before = std::env::current_dir().unwrap();
129        let workspace = TestWorkspace::new("cwd-guard");
130        // Canonicalize: macOS temp dirs resolve through a `/private` symlink.
131        let target = workspace.path().canonicalize().unwrap();
132
133        {
134            let guard = CurrentDirGuard::change_to(&target).unwrap();
135            assert_eq!(std::env::current_dir().unwrap(), target);
136            assert_eq!(guard.previous(), before);
137        }
138
139        assert_eq!(std::env::current_dir().unwrap(), before);
140    }
141
142    #[test]
143    fn pin_holds_the_current_directory() {
144        let before = std::env::current_dir().unwrap();
145
146        {
147            let _guard = CurrentDirGuard::pin().unwrap();
148            assert_eq!(std::env::current_dir().unwrap(), before);
149        }
150
151        assert_eq!(std::env::current_dir().unwrap(), before);
152    }
153
154    #[test]
155    fn change_to_rejects_a_missing_directory() {
156        let workspace = TestWorkspace::new("cwd-missing");
157        let missing = workspace.path().join("does-not-exist");
158
159        let error = CurrentDirGuard::change_to(&missing).unwrap_err();
160
161        assert!(
162            error
163                .to_string()
164                .contains("failed to set current directory")
165        );
166    }
167
168    #[test]
169    fn guards_serialize_across_threads() {
170        use std::sync::Arc;
171        use std::sync::Barrier;
172        use std::sync::atomic::{AtomicUsize, Ordering};
173
174        // Each thread holds a guard while incrementing a shared counter;
175        // if the process-wide lock serializes guards, the counter never exceeds one.
176        const THREADS: usize = 8;
177        let barrier = Arc::new(Barrier::new(THREADS));
178        let live = Arc::new(AtomicUsize::new(0));
179        let peak = Arc::new(AtomicUsize::new(0));
180
181        let handles: Vec<_> = (0..THREADS)
182            .map(|_| {
183                let barrier = Arc::clone(&barrier);
184                let live = Arc::clone(&live);
185                let peak = Arc::clone(&peak);
186                std::thread::spawn(move || {
187                    // Release every thread together to maximize contention without depending on wall-clock sleeps.
188                    barrier.wait();
189                    let _guard = CurrentDirGuard::pin().unwrap();
190                    let now = live.fetch_add(1, Ordering::SeqCst) + 1;
191                    peak.fetch_max(now, Ordering::SeqCst);
192                    // Hold the guard across a bounded spin so a missing lock would
193                    // let a sibling observe `live > 1` — deterministic, not
194                    // timing-dependent.
195                    for _ in 0..10_000 {
196                        std::hint::spin_loop();
197                    }
198                    live.fetch_sub(1, Ordering::SeqCst);
199                })
200            })
201            .collect();
202
203        for handle in handles {
204            handle.join().unwrap();
205        }
206
207        assert_eq!(
208            peak.load(Ordering::SeqCst),
209            1,
210            "guards must never be held concurrently across threads"
211        );
212    }
213
214    #[test]
215    fn nested_guards_on_one_thread_do_not_deadlock() {
216        // The reentrant lock allows a thread to hold nested guards;
217        // a non-reentrant lock would deadlock here instead.
218        let _outer = CurrentDirGuard::pin().unwrap();
219        let _inner = CurrentDirGuard::pin().unwrap();
220    }
221}