1use std::{
7 fs::{self, File, OpenOptions},
8 path::{Path, PathBuf},
9};
10
11use crate::error::{LockError, LockResult};
12
13pub struct FileLock {
17 _file: nix::fcntl::Flock<File>,
18 path: PathBuf,
19}
20
21impl FileLock {
22 fn lock_dir() -> LockResult<PathBuf> {
26 let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok();
27 let base = if let Some(ref runtime) = xdg_runtime {
28 PathBuf::from(runtime)
29 } else {
30 std::env::temp_dir()
31 };
32
33 let lock_dir = base.join("soar").join("locks");
34
35 if !lock_dir.exists() {
36 fs::create_dir_all(&lock_dir)?;
37 }
38
39 Ok(lock_dir)
40 }
41
42 fn lock_path(name: &str) -> LockResult<PathBuf> {
44 let lock_dir = Self::lock_dir()?;
45
46 let sanitize = |s: &str| {
48 s.chars()
49 .map(|c| {
50 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
51 c
52 } else {
53 '_'
54 }
55 })
56 .collect::<String>()
57 };
58
59 let filename = format!("{}.lock", sanitize(name));
60 Ok(lock_dir.join(filename))
61 }
62
63 pub fn acquire(name: &str) -> LockResult<Self> {
75 let lock_path = Self::lock_path(name)?;
76
77 let file = OpenOptions::new()
78 .write(true)
79 .create(true)
80 .truncate(false)
81 .open(&lock_path)?;
82
83 let file = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive).map_err(
84 |(_, err)| LockError::AcquireFailed(format!("{}: {}", lock_path.display(), err)),
85 )?;
86
87 Ok(FileLock {
88 path: lock_path,
89 _file: file,
90 })
91 }
92
93 pub fn try_acquire(name: &str) -> LockResult<Option<Self>> {
101 let lock_path = Self::lock_path(name)?;
102
103 let file = OpenOptions::new()
104 .write(true)
105 .create(true)
106 .truncate(false)
107 .open(&lock_path)?;
108
109 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
110 Ok(file) => {
111 Ok(Some(FileLock {
112 path: lock_path,
113 _file: file,
114 }))
115 }
116 Err((_, err)) => {
117 if matches!(err, nix::errno::Errno::EWOULDBLOCK) {
118 return Ok(None);
119 }
120 Err(LockError::AcquireFailed(format!(
121 "{}: {}",
122 lock_path.display(),
123 err
124 )))
125 }
126 }
127 }
128
129 pub fn path(&self) -> &Path {
131 &self.path
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use std::{thread, time::Duration};
138
139 use super::*;
140
141 #[test]
142 fn test_lock_path_generation() {
143 let path = FileLock::lock_path("test-pkg").unwrap();
144 assert!(path.to_string_lossy().ends_with("test-pkg.lock"));
145 }
146
147 #[test]
148 fn test_lock_sanitization() {
149 let path = FileLock::lock_path("test/pkg").unwrap();
150 assert!(path.to_string_lossy().contains("test_pkg"));
151 }
152
153 #[test]
154 fn test_exclusive_lock() {
155 let lock1 = FileLock::acquire("test-exclusive").unwrap();
156
157 let lock2 = FileLock::try_acquire("test-exclusive").unwrap();
158 assert!(lock2.is_none(), "Should not be able to acquire lock");
159
160 drop(lock1);
161
162 let lock3 = FileLock::try_acquire("test-exclusive").unwrap();
163 assert!(
164 lock3.is_some(),
165 "Should be able to acquire lock after release"
166 );
167 }
168
169 #[test]
170 fn test_concurrent_locks_different_packages() {
171 let lock1 = FileLock::acquire("pkg-a").unwrap();
172 let lock2 = FileLock::acquire("pkg-b").unwrap();
173
174 assert!(lock1.path() != lock2.path());
175 }
176
177 #[test]
178 fn test_lock_blocks_until_released() {
179 let lock1 = FileLock::acquire("test-block").unwrap();
180 let path = lock1.path().to_path_buf();
181
182 let handle = thread::spawn(move || {
183 let lock2 = FileLock::acquire("test-block").unwrap();
184 assert_eq!(lock2.path(), &path);
185 });
186
187 thread::sleep(Duration::from_millis(100));
188
189 drop(lock1);
190
191 handle.join().unwrap();
192 }
193}