1use std::fs::{File, OpenOptions, remove_file};
6use std::io::{Write, Read, Seek, SeekFrom};
7use std::path::{Path, PathBuf};
8use std::process;
9
10use fs2::FileExt;
11use serde::{Serialize, Deserialize};
12use thiserror::Error;
13use time::OffsetDateTime;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LockInfo {
18 pub program: String,
20 pub pid: u32,
22 pub username: String,
24 pub started_at: String,
26}
27
28#[derive(Debug, Error)]
30pub enum LockError {
31 #[error("Directory is already locked by {program} (PID {pid}, user {username}, started at {started_at})")]
33 AlreadyLocked {
34 program: String,
35 pid: u32,
36 username: String,
37 started_at: String,
38 },
39 #[error("IO error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("JSON error: {0}")]
44 Json(#[from] serde_json::Error),
45 #[error("Failed to create .robit directory: {0}")]
47 CreateDir(std::io::Error),
48}
49
50pub struct DirectoryLock {
52 lock_path: PathBuf,
54 file: Option<File>,
56 info: LockInfo,
58}
59
60impl DirectoryLock {
61 pub fn acquire(workdir: &Path, program_name: &str) -> Result<Self, LockError> {
67 let robit_dir = workdir.join(".robit");
68 let lock_path = robit_dir.join("LOCK");
69
70 tracing::debug!("Acquiring directory lock at: {}", lock_path.display());
71
72 if !robit_dir.exists() {
74 tracing::debug!("Creating .robit directory at: {}", robit_dir.display());
75 std::fs::create_dir_all(&robit_dir)
76 .map_err(LockError::CreateDir)?;
77 }
78
79 let username = get_username().unwrap_or_else(|| "unknown".to_string());
81
82 let info = LockInfo {
84 program: program_name.to_string(),
85 pid: process::id(),
86 username,
87 started_at: OffsetDateTime::now_utc()
88 .format(&time::format_description::well_known::Rfc3339)
89 .unwrap_or_else(|_| "unknown".to_string()),
90 };
91
92 match Self::try_acquire(&lock_path, &info) {
94 Ok(lock) => Ok(lock),
95 Err(LockError::AlreadyLocked { program, pid, username, started_at }) => {
96 if !is_process_running(pid) {
98 tracing::warn!(
100 "Found stale lock file from {} (PID {}, user {}, started at {}), cleaning up",
101 program,
102 pid,
103 username,
104 started_at
105 );
106 let _ = remove_file(&lock_path);
107 Self::try_acquire(&lock_path, &info)
108 } else {
109 Err(LockError::AlreadyLocked { program, pid, username, started_at })
110 }
111 }
112 Err(e) => Err(e),
113 }
114 }
115
116 fn try_acquire(lock_path: &Path, info: &LockInfo) -> Result<Self, LockError> {
118 let mut file = OpenOptions::new()
120 .read(true)
121 .write(true)
122 .create(true)
123 .open(lock_path)?;
124
125 match file.try_lock_exclusive() {
127 Ok(_) => {
128 file.set_len(0)?;
130 file.seek(SeekFrom::Start(0))?;
131 serde_json::to_writer_pretty(&mut file, info)?;
132 file.flush()?;
133 file.sync_data()?;
134
135 tracing::info!("Acquired directory lock at: {}", lock_path.display());
136 tracing::debug!("Lock info: {:?}", info);
137
138 Ok(Self {
139 lock_path: lock_path.to_path_buf(),
140 file: Some(file),
141 info: info.clone(),
142 })
143 }
144 Err(_) => {
145 let mut content = String::new();
147 file.seek(SeekFrom::Start(0))?;
148 file.read_to_string(&mut content)?;
149
150 let existing_info: LockInfo = match serde_json::from_str(&content) {
151 Ok(info) => info,
152 Err(_) => {
153 LockInfo {
155 program: "unknown".to_string(),
156 pid: 0,
157 username: "unknown".to_string(),
158 started_at: "unknown".to_string(),
159 }
160 }
161 };
162
163 Err(LockError::AlreadyLocked {
164 program: existing_info.program,
165 pid: existing_info.pid,
166 username: existing_info.username,
167 started_at: existing_info.started_at,
168 })
169 }
170 }
171 }
172
173 pub fn info(&self) -> &LockInfo {
175 &self.info
176 }
177
178 pub fn release(&mut self) {
180 if let Some(file) = self.file.take() {
181 let _ = file.unlock();
182 }
183 }
184}
185
186impl Drop for DirectoryLock {
187 fn drop(&mut self) {
188 tracing::debug!("Releasing directory lock at: {}", self.lock_path.display());
189 if let Some(file) = self.file.take() {
190 let _ = file.unlock();
191 }
192 let result = std::fs::remove_file(&self.lock_path);
194 match result {
195 Ok(_) => tracing::debug!("Deleted lock file: {}", self.lock_path.display()),
196 Err(e) => tracing::debug!("Failed to delete lock file: {}", e),
197 }
198 }
199}
200
201fn is_process_running(pid: u32) -> bool {
203 #[cfg(unix)]
204 {
205 use std::process::Command;
206 let output = Command::new("kill")
208 .arg("-0")
209 .arg(pid.to_string())
210 .output();
211 match output {
212 Ok(output) => {
213 output.status.success() || output.status.code() == Some(1)
215 }
216 Err(_) => {
217 true
219 }
220 }
221 }
222
223 #[cfg(windows)]
224 {
225 use std::process::Command;
226 let output = Command::new("tasklist")
228 .arg("/FI")
229 .arg(format!("PID eq {}", pid))
230 .output();
231 match output {
232 Ok(output) => {
233 let output_str = String::from_utf8_lossy(&output.stdout);
234 output_str.contains(&pid.to_string())
235 }
236 Err(_) => {
237 true
239 }
240 }
241 }
242
243 #[cfg(not(any(unix, windows)))]
244 {
245 true
247 }
248}
249
250fn get_username() -> Option<String> {
252 if let Ok(name) = std::env::var("USER") {
254 if !name.is_empty() {
255 return Some(name);
256 }
257 }
258 if let Ok(name) = std::env::var("USERNAME") {
259 if !name.is_empty() {
260 return Some(name);
261 }
262 }
263
264 #[cfg(unix)]
266 {
267 use std::process::Command;
268 if let Ok(output) = Command::new("whoami").output() {
269 if output.status.success() {
270 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
271 if !name.is_empty() {
272 return Some(name);
273 }
274 }
275 }
276 }
277
278 None
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use tempfile::tempdir;
285
286 #[test]
287 fn test_lock_acquire_and_release() {
288 let dir = tempdir().unwrap();
289 let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
290 assert_eq!(lock.info().program, "test-program");
291 assert!(lock.info().pid > 0);
292 }
293
294 #[test]
295 fn test_username_is_set() {
296 let dir = tempdir().unwrap();
297 let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
298 assert!(!lock.info().username.is_empty());
299 }
300
301 #[test]
302 fn test_started_at_is_set() {
303 let dir = tempdir().unwrap();
304 let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
305 assert!(!lock.info().started_at.is_empty());
306 }
307
308 #[test]
309 fn test_is_process_running_with_our_pid() {
310 assert!(is_process_running(std::process::id()));
312 }
313
314 #[test]
315 fn test_is_process_running_with_invalid_pid() {
316 assert!(!is_process_running(u32::MAX));
319 }
320}