Skip to main content

thoughts_tool/utils/
locks.rs

1//! Advisory file locking utilities.
2//!
3//! This module provides a thin RAII wrapper around `std::fs` file locking (Rust 1.89+).
4//! Used for:
5//! - Protecting `repos.json` read-modify-write operations
6//! - Per-repo clone locks to prevent concurrent clones into the same target
7
8use anyhow::Context;
9use anyhow::Result;
10use std::fs::File;
11use std::fs::OpenOptions;
12use std::fs::TryLockError;
13use std::path::Path;
14use std::path::PathBuf;
15
16/// RAII advisory file lock.
17///
18/// The lock is automatically released when this struct is dropped.
19/// Uses advisory locking via std, which works across processes on Unix systems.
20pub struct FileLock {
21    _file: File,
22    pub path: PathBuf,
23}
24
25impl FileLock {
26    /// Acquire an exclusive lock, blocking until available.
27    ///
28    /// Creates the lock file and parent directories if they don't exist.
29    pub fn lock_exclusive(path: impl AsRef<Path>) -> Result<Self> {
30        let path = path.as_ref().to_path_buf();
31        if let Some(parent) = path.parent() {
32            std::fs::create_dir_all(parent)
33                .with_context(|| format!("Failed to create lock dir: {}", parent.display()))?;
34        }
35        let file = OpenOptions::new()
36            .read(true)
37            .write(true)
38            .create(true)
39            .truncate(false)
40            .open(&path)
41            .with_context(|| format!("Failed to open lock file: {}", path.display()))?;
42        file.lock()
43            .with_context(|| format!("Failed to acquire exclusive lock: {}", path.display()))?;
44        Ok(Self { _file: file, path })
45    }
46
47    /// Try to acquire an exclusive lock without blocking.
48    ///
49    /// Returns `Ok(Some(lock))` if the lock was acquired, `Ok(None)` if the lock
50    /// is held by another process, or an error for other failures.
51    pub fn try_lock_exclusive(path: impl AsRef<Path>) -> Result<Option<Self>> {
52        let path = path.as_ref().to_path_buf();
53        if let Some(parent) = path.parent() {
54            std::fs::create_dir_all(parent)
55                .with_context(|| format!("Failed to create lock dir: {}", parent.display()))?;
56        }
57        let file = OpenOptions::new()
58            .read(true)
59            .write(true)
60            .create(true)
61            .truncate(false)
62            .open(&path)
63            .with_context(|| format!("Failed to open lock file: {}", path.display()))?;
64        match file.try_lock() {
65            Ok(()) => Ok(Some(Self { _file: file, path })),
66            Err(TryLockError::WouldBlock) => Ok(None), // Lock not acquired (would block)
67            Err(TryLockError::Error(e)) => Err(anyhow::Error::from(e).context(format!(
68                "Failed to acquire exclusive lock: {}",
69                path.display()
70            ))),
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use tempfile::TempDir;
79
80    #[test]
81    fn test_lock_exclusive_basic() {
82        let dir = TempDir::new().unwrap();
83        let lock_path = dir.path().join("test.lock");
84
85        let lock = FileLock::lock_exclusive(&lock_path).unwrap();
86        assert!(lock_path.exists());
87        drop(lock);
88    }
89
90    #[test]
91    fn test_lock_creates_parent_dirs() {
92        let dir = TempDir::new().unwrap();
93        let lock_path = dir.path().join("nested").join("dirs").join("test.lock");
94
95        let lock = FileLock::lock_exclusive(&lock_path).unwrap();
96        assert!(lock_path.exists());
97        drop(lock);
98    }
99
100    #[test]
101    fn test_try_lock_exclusive_succeeds_when_available() {
102        let dir = TempDir::new().unwrap();
103        let lock_path = dir.path().join("test.lock");
104
105        let lock = FileLock::try_lock_exclusive(&lock_path).unwrap();
106        assert!(lock.is_some());
107    }
108
109    #[test]
110    fn test_try_lock_exclusive_fails_when_held() {
111        let dir = TempDir::new().unwrap();
112        let lock_path = dir.path().join("test.lock");
113
114        let _lock1 = FileLock::lock_exclusive(&lock_path).unwrap();
115        let lock2 = FileLock::try_lock_exclusive(&lock_path).unwrap();
116
117        assert!(
118            lock2.is_none(),
119            "Second lock should fail when first is held"
120        );
121    }
122}