1use std::fs::{File, OpenOptions};
34use std::io;
35use std::path::{Path, PathBuf};
36
37#[derive(Debug)]
43pub struct FileLock {
44 lock_path: PathBuf,
46}
47
48impl FileLock {
49 pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
54 let path = path.as_ref();
55 let lock_path = path.with_extension(format!(
56 "{}.lock",
57 path.extension()
58 .map(|s| s.to_string_lossy().to_string())
59 .unwrap_or_default()
60 ));
61
62 if let Some(parent) = lock_path.parent() {
64 std::fs::create_dir_all(parent)?;
65 }
66
67 Ok(Self { lock_path })
68 }
69
70 pub fn shared(&self) -> io::Result<LockGuard> {
75 self.acquire(LockType::Shared)
76 }
77
78 pub fn try_shared(&self) -> io::Result<Option<LockGuard>> {
82 self.try_acquire(LockType::Shared)
83 }
84
85 pub fn exclusive(&self) -> io::Result<LockGuard> {
90 self.acquire(LockType::Exclusive)
91 }
92
93 pub fn try_exclusive(&self) -> io::Result<Option<LockGuard>> {
97 self.try_acquire(LockType::Exclusive)
98 }
99
100 fn acquire(&self, lock_type: LockType) -> io::Result<LockGuard> {
102 let file = self.open_lock_file()?;
103
104 #[cfg(unix)]
105 {
106 use nix::fcntl::{Flock, FlockArg};
107
108 let arg = match lock_type {
109 LockType::Shared => FlockArg::LockShared,
110 LockType::Exclusive => FlockArg::LockExclusive,
111 };
112
113 match Flock::lock(file, arg) {
114 Ok(flock) => Ok(LockGuard {
115 _flock: flock,
116 _lock_type: lock_type,
117 }),
118 Err((_, errno)) => Err(io::Error::new(
119 io::ErrorKind::Other,
120 format!("flock failed: {}", errno),
121 )),
122 }
123 }
124
125 #[cfg(not(unix))]
126 {
127 let _ = (file, lock_type);
128 Err(io::Error::new(
129 io::ErrorKind::Unsupported,
130 "File locking not supported on this platform",
131 ))
132 }
133 }
134
135 fn try_acquire(&self, lock_type: LockType) -> io::Result<Option<LockGuard>> {
137 let file = self.open_lock_file()?;
138
139 #[cfg(unix)]
140 {
141 use nix::errno::Errno;
142 use nix::fcntl::{Flock, FlockArg};
143
144 let arg = match lock_type {
145 LockType::Shared => FlockArg::LockSharedNonblock,
146 LockType::Exclusive => FlockArg::LockExclusiveNonblock,
147 };
148
149 match Flock::lock(file, arg) {
150 Ok(flock) => Ok(Some(LockGuard {
151 _flock: flock,
152 _lock_type: lock_type,
153 })),
154 Err((_, errno)) if errno == Errno::EWOULDBLOCK || errno == Errno::EAGAIN => {
155 Ok(None)
156 }
157 Err((_, errno)) => Err(io::Error::new(
158 io::ErrorKind::Other,
159 format!("flock failed: {}", errno),
160 )),
161 }
162 }
163
164 #[cfg(not(unix))]
165 {
166 let _ = (file, lock_type);
167 Err(io::Error::new(
168 io::ErrorKind::Unsupported,
169 "File locking not supported on this platform",
170 ))
171 }
172 }
173
174 fn open_lock_file(&self) -> io::Result<File> {
176 OpenOptions::new()
177 .read(true)
178 .write(true)
179 .create(true)
180 .truncate(false)
181 .open(&self.lock_path)
182 }
183
184 pub fn lock_path(&self) -> &Path {
186 &self.lock_path
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum LockType {
193 Shared,
195 Exclusive,
197}
198
199#[derive(Debug)]
201pub struct LockGuard {
202 #[cfg(unix)]
204 _flock: nix::fcntl::Flock<File>,
205
206 _lock_type: LockType,
208}
209
210pub struct LockedFile {
215 lock: FileLock,
216}
217
218impl LockedFile {
219 pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
221 Ok(Self {
222 lock: FileLock::new(path)?,
223 })
224 }
225
226 pub fn read(&self, path: &Path) -> io::Result<String> {
230 let _guard = self.lock.shared()?;
231 if path.exists() {
232 std::fs::read_to_string(path)
233 } else {
234 Ok(String::new())
235 }
236 }
237
238 pub fn write(&self, path: &Path, content: &str) -> io::Result<()> {
240 let _guard = self.lock.exclusive()?;
241 if let Some(parent) = path.parent() {
243 std::fs::create_dir_all(parent)?;
244 }
245 std::fs::write(path, content)
246 }
247
248 pub fn with_shared_lock<T, F>(&self, f: F) -> io::Result<T>
250 where
251 F: FnOnce() -> io::Result<T>,
252 {
253 let _guard = self.lock.shared()?;
254 f()
255 }
256
257 pub fn with_exclusive_lock<T, F>(&self, f: F) -> io::Result<T>
259 where
260 F: FnOnce() -> io::Result<T>,
261 {
262 let _guard = self.lock.exclusive()?;
263 f()
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::sync::{Arc, Barrier};
271 use std::thread;
272 use std::time::{Duration, Instant};
273 use tempfile::TempDir;
274
275 #[test]
276 fn test_lock_file_path() {
277 let temp_dir = TempDir::new().unwrap();
278 let file_path = temp_dir.path().join("test.jsonl");
279
280 let lock = FileLock::new(&file_path).unwrap();
281 assert_eq!(lock.lock_path(), temp_dir.path().join("test.jsonl.lock"));
282 }
283
284 #[test]
285 fn test_lock_file_path_no_extension() {
286 let temp_dir = TempDir::new().unwrap();
287 let file_path = temp_dir.path().join("tasks");
288
289 let lock = FileLock::new(&file_path).unwrap();
290 assert_eq!(lock.lock_path(), temp_dir.path().join("tasks..lock"));
291 }
292
293 #[test]
294 fn test_shared_lock_acquired() {
295 let temp_dir = TempDir::new().unwrap();
296 let file_path = temp_dir.path().join("test.txt");
297
298 let lock = FileLock::new(&file_path).unwrap();
299 let guard = lock.shared();
300 assert!(guard.is_ok());
301 }
302
303 #[test]
304 fn test_exclusive_lock_acquired() {
305 let temp_dir = TempDir::new().unwrap();
306 let file_path = temp_dir.path().join("test.txt");
307
308 let lock = FileLock::new(&file_path).unwrap();
309 let guard = lock.exclusive();
310 assert!(guard.is_ok());
311 }
312
313 #[test]
314 fn test_multiple_shared_locks() {
315 let temp_dir = TempDir::new().unwrap();
317 let file_path = temp_dir.path().join("test.txt");
318
319 let lock1 = FileLock::new(&file_path).unwrap();
320 let lock2 = FileLock::new(&file_path).unwrap();
321
322 let _guard1 = lock1.shared().unwrap();
323 let guard2 = lock2.try_shared();
324
325 assert!(guard2.is_ok());
327 assert!(guard2.unwrap().is_some());
328 }
329
330 #[test]
331 fn test_exclusive_blocks_shared() {
332 let temp_dir = TempDir::new().unwrap();
334 let file_path = temp_dir.path().join("test.txt");
335
336 let lock1 = FileLock::new(&file_path).unwrap();
337 let lock2 = FileLock::new(&file_path).unwrap();
338
339 let _guard1 = lock1.exclusive().unwrap();
340 let guard2 = lock2.try_shared();
341
342 assert!(guard2.is_ok());
344 assert!(guard2.unwrap().is_none());
345 }
346
347 #[test]
348 fn test_shared_blocks_exclusive() {
349 let temp_dir = TempDir::new().unwrap();
351 let file_path = temp_dir.path().join("test.txt");
352
353 let lock1 = FileLock::new(&file_path).unwrap();
354 let lock2 = FileLock::new(&file_path).unwrap();
355
356 let _guard1 = lock1.shared().unwrap();
357 let guard2 = lock2.try_exclusive();
358
359 assert!(guard2.is_ok());
361 assert!(guard2.unwrap().is_none());
362 }
363
364 #[test]
365 fn test_lock_released_on_drop() {
366 let temp_dir = TempDir::new().unwrap();
367 let file_path = temp_dir.path().join("test.txt");
368
369 let lock1 = FileLock::new(&file_path).unwrap();
370 let lock2 = FileLock::new(&file_path).unwrap();
371
372 {
373 let _guard1 = lock1.exclusive().unwrap();
374 }
376 let guard2 = lock2.try_exclusive();
380 assert!(guard2.is_ok());
381 assert!(guard2.unwrap().is_some());
382 }
383
384 #[test]
385 fn test_locked_file_read_write() {
386 let temp_dir = TempDir::new().unwrap();
387 let file_path = temp_dir.path().join("test.txt");
388
389 let locked = LockedFile::new(&file_path).unwrap();
390
391 locked.write(&file_path, "Hello, World!").unwrap();
393
394 let content = locked.read(&file_path).unwrap();
396 assert_eq!(content, "Hello, World!");
397 }
398
399 #[test]
400 fn test_locked_file_read_nonexistent() {
401 let temp_dir = TempDir::new().unwrap();
402 let file_path = temp_dir.path().join("nonexistent.txt");
403
404 let locked = LockedFile::new(&file_path).unwrap();
405 let content = locked.read(&file_path).unwrap();
406 assert!(content.is_empty());
407 }
408
409 #[test]
410 fn test_concurrent_writes_serialized() {
411 let temp_dir = TempDir::new().unwrap();
413 let file_path = temp_dir.path().join("counter.txt");
414 let file_path_clone = file_path.clone();
415
416 std::fs::write(&file_path, "0").unwrap();
418
419 let barrier = Arc::new(Barrier::new(2));
420 let barrier_clone = barrier.clone();
421
422 let handle1 = thread::spawn(move || {
423 let locked = LockedFile::new(&file_path).unwrap();
424 barrier.wait();
425
426 locked
427 .with_exclusive_lock(|| {
428 let content = std::fs::read_to_string(&file_path)?;
429 let n: i32 = content.trim().parse().unwrap_or(0);
430 thread::sleep(Duration::from_millis(10));
432 std::fs::write(&file_path, format!("{}", n + 1))
433 })
434 .unwrap();
435 });
436
437 let handle2 = thread::spawn(move || {
438 let locked = LockedFile::new(&file_path_clone).unwrap();
439 barrier_clone.wait();
440
441 locked
442 .with_exclusive_lock(|| {
443 let content = std::fs::read_to_string(&file_path_clone)?;
444 let n: i32 = content.trim().parse().unwrap_or(0);
445 thread::sleep(Duration::from_millis(10));
446 std::fs::write(&file_path_clone, format!("{}", n + 1))
447 })
448 .unwrap();
449 });
450
451 handle1.join().unwrap();
452 handle2.join().unwrap();
453
454 let final_content = std::fs::read_to_string(temp_dir.path().join("counter.txt")).unwrap();
456 assert_eq!(final_content.trim(), "2");
457 }
458
459 #[test]
460 fn test_blocking_lock_waits() {
461 let temp_dir = TempDir::new().unwrap();
462 let file_path = temp_dir.path().join("wait.txt");
463 let file_path_clone = file_path.clone();
464
465 let barrier = Arc::new(Barrier::new(2));
466 let barrier_clone = barrier.clone();
467
468 let handle1 = thread::spawn(move || {
469 let lock = FileLock::new(&file_path).unwrap();
470 let _guard = lock.exclusive().unwrap();
471
472 barrier.wait();
473 thread::sleep(Duration::from_millis(50));
475 });
477
478 let start = Instant::now();
479 let handle2 = thread::spawn(move || {
480 let lock = FileLock::new(&file_path_clone).unwrap();
481 barrier_clone.wait();
482
483 let _guard = lock.exclusive().unwrap();
485 });
486
487 handle1.join().unwrap();
488 handle2.join().unwrap();
489
490 assert!(start.elapsed() >= Duration::from_millis(40));
492 }
493}