1use std::fs::{self, File, OpenOptions, TryLockError};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16
17use sysinfo::{Pid, ProcessesToUpdate, System};
18
19use crate::error::MifRhError;
20
21#[derive(Debug)]
24pub struct ReviewLock {
25 file: File,
26 path: PathBuf,
27}
28
29impl ReviewLock {
30 pub fn acquire(path: &Path) -> Result<Self, MifRhError> {
39 if let Some(holder_pid) = read_holder_pid(path)
40 && process_is_alive(holder_pid)
41 {
42 return Err(MifRhError::LockHeld { holder_pid });
43 }
44
45 let mut file = OpenOptions::new()
46 .create(true)
47 .truncate(false)
48 .read(true)
49 .write(true)
50 .open(path)
51 .map_err(|source| MifRhError::LockIo {
52 path: path.display().to_string(),
53 source,
54 })?;
55
56 match file.try_lock() {
57 Ok(()) => {},
58 Err(TryLockError::WouldBlock) => {
59 return Err(MifRhError::LockHeld {
60 holder_pid: read_holder_pid(path).unwrap_or(0),
61 });
62 },
63 Err(TryLockError::Error(source)) => {
64 return Err(MifRhError::LockIo {
65 path: path.display().to_string(),
66 source,
67 });
68 },
69 }
70
71 file.set_len(0).map_err(|source| MifRhError::LockIo {
72 path: path.display().to_string(),
73 source,
74 })?;
75 file.write_all(std::process::id().to_string().as_bytes())
76 .map_err(|source| MifRhError::LockIo {
77 path: path.display().to_string(),
78 source,
79 })?;
80
81 Ok(Self {
82 file,
83 path: path.to_path_buf(),
84 })
85 }
86}
87
88impl Drop for ReviewLock {
89 fn drop(&mut self) {
90 let _ = self.file.unlock();
91 let _ = fs::remove_file(&self.path);
92 }
93}
94
95fn read_holder_pid(path: &Path) -> Option<u32> {
96 let mut contents = String::new();
97 File::open(path).ok()?.read_to_string(&mut contents).ok()?;
98 contents.trim().parse().ok()
99}
100
101fn process_is_alive(pid: u32) -> bool {
102 let mut system = System::new();
103 system.refresh_processes(ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
104 system.process(Pid::from_u32(pid)).is_some()
105}
106
107#[cfg(test)]
108mod tests {
109 use super::ReviewLock;
110
111 #[test]
112 fn acquire_creates_and_releases_the_lock_file() {
113 let dir = tempfile::tempdir().unwrap();
114 let path = dir.path().join(".review.lock");
115 assert!(!path.exists());
116
117 {
118 let _lock = ReviewLock::acquire(&path).unwrap();
119 assert!(path.exists());
120 }
121 assert!(!path.exists(), "lock file should be removed on drop");
122 }
123
124 #[test]
125 fn a_second_acquire_while_the_first_is_held_fails_closed() {
126 let dir = tempfile::tempdir().unwrap();
127 let path = dir.path().join(".review.lock");
128
129 let _first = ReviewLock::acquire(&path).unwrap();
130 let error = ReviewLock::acquire(&path).unwrap_err();
131 assert!(matches!(error, super::MifRhError::LockHeld { .. }));
132 }
133
134 #[test]
135 fn a_stale_lock_from_a_dead_pid_is_cleared_automatically() {
136 let dir = tempfile::tempdir().unwrap();
137 let path = dir.path().join(".review.lock");
138 std::fs::write(&path, "999999999").unwrap();
140
141 let lock = ReviewLock::acquire(&path);
142 assert!(
143 lock.is_ok(),
144 "expected a stale lock to be cleared, got {lock:?}"
145 );
146 }
147
148 #[test]
149 fn acquiring_again_after_release_succeeds() {
150 let dir = tempfile::tempdir().unwrap();
151 let path = dir.path().join(".review.lock");
152
153 drop(ReviewLock::acquire(&path).unwrap());
154 let second = ReviewLock::acquire(&path);
155 assert!(second.is_ok());
156 }
157}