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