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}