use std::fs::{File, OpenOptions, remove_file};
use std::io::{Write, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::process;
use fs2::FileExt;
use serde::{Serialize, Deserialize};
use thiserror::Error;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub program: String,
pub pid: u32,
pub username: String,
pub started_at: String,
}
#[derive(Debug, Error)]
pub enum LockError {
#[error("Directory is already locked by {program} (PID {pid}, user {username}, started at {started_at})")]
AlreadyLocked {
program: String,
pid: u32,
username: String,
started_at: String,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Failed to create .robit directory: {0}")]
CreateDir(std::io::Error),
}
pub struct DirectoryLock {
lock_path: PathBuf,
file: Option<File>,
info: LockInfo,
}
impl DirectoryLock {
pub fn acquire(workdir: &Path, program_name: &str) -> Result<Self, LockError> {
let robit_dir = workdir.join(".robit");
let lock_path = robit_dir.join("LOCK");
tracing::debug!("Acquiring directory lock at: {}", lock_path.display());
if !robit_dir.exists() {
tracing::debug!("Creating .robit directory at: {}", robit_dir.display());
std::fs::create_dir_all(&robit_dir)
.map_err(LockError::CreateDir)?;
}
let username = get_username().unwrap_or_else(|| "unknown".to_string());
let info = LockInfo {
program: program_name.to_string(),
pid: process::id(),
username,
started_at: OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".to_string()),
};
match Self::try_acquire(&lock_path, &info) {
Ok(lock) => Ok(lock),
Err(LockError::AlreadyLocked { program, pid, username, started_at }) => {
if !is_process_running(pid) {
tracing::warn!(
"Found stale lock file from {} (PID {}, user {}, started at {}), cleaning up",
program,
pid,
username,
started_at
);
let _ = remove_file(&lock_path);
Self::try_acquire(&lock_path, &info)
} else {
Err(LockError::AlreadyLocked { program, pid, username, started_at })
}
}
Err(e) => Err(e),
}
}
fn try_acquire(lock_path: &Path, info: &LockInfo) -> Result<Self, LockError> {
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(lock_path)?;
match file.try_lock_exclusive() {
Ok(_) => {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
serde_json::to_writer_pretty(&mut file, info)?;
file.flush()?;
file.sync_data()?;
tracing::info!("Acquired directory lock at: {}", lock_path.display());
tracing::debug!("Lock info: {:?}", info);
Ok(Self {
lock_path: lock_path.to_path_buf(),
file: Some(file),
info: info.clone(),
})
}
Err(_) => {
let mut content = String::new();
file.seek(SeekFrom::Start(0))?;
file.read_to_string(&mut content)?;
let existing_info: LockInfo = match serde_json::from_str(&content) {
Ok(info) => info,
Err(_) => {
LockInfo {
program: "unknown".to_string(),
pid: 0,
username: "unknown".to_string(),
started_at: "unknown".to_string(),
}
}
};
Err(LockError::AlreadyLocked {
program: existing_info.program,
pid: existing_info.pid,
username: existing_info.username,
started_at: existing_info.started_at,
})
}
}
}
pub fn info(&self) -> &LockInfo {
&self.info
}
pub fn release(&mut self) {
if let Some(file) = self.file.take() {
let _ = file.unlock();
}
}
}
impl Drop for DirectoryLock {
fn drop(&mut self) {
tracing::debug!("Releasing directory lock at: {}", self.lock_path.display());
if let Some(file) = self.file.take() {
let _ = file.unlock();
}
let result = std::fs::remove_file(&self.lock_path);
match result {
Ok(_) => tracing::debug!("Deleted lock file: {}", self.lock_path.display()),
Err(e) => tracing::debug!("Failed to delete lock file: {}", e),
}
}
}
fn is_process_running(pid: u32) -> bool {
#[cfg(unix)]
{
use std::process::Command;
let output = Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.output();
match output {
Ok(output) => {
output.status.success() || output.status.code() == Some(1)
}
Err(_) => {
true
}
}
}
#[cfg(windows)]
{
use std::process::Command;
let output = Command::new("tasklist")
.arg("/FI")
.arg(format!("PID eq {}", pid))
.output();
match output {
Ok(output) => {
let output_str = String::from_utf8_lossy(&output.stdout);
output_str.contains(&pid.to_string())
}
Err(_) => {
true
}
}
}
#[cfg(not(any(unix, windows)))]
{
true
}
}
fn get_username() -> Option<String> {
if let Ok(name) = std::env::var("USER") {
if !name.is_empty() {
return Some(name);
}
}
if let Ok(name) = std::env::var("USERNAME") {
if !name.is_empty() {
return Some(name);
}
}
#[cfg(unix)]
{
use std::process::Command;
if let Ok(output) = Command::new("whoami").output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return Some(name);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_lock_acquire_and_release() {
let dir = tempdir().unwrap();
let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
assert_eq!(lock.info().program, "test-program");
assert!(lock.info().pid > 0);
}
#[test]
fn test_username_is_set() {
let dir = tempdir().unwrap();
let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
assert!(!lock.info().username.is_empty());
}
#[test]
fn test_started_at_is_set() {
let dir = tempdir().unwrap();
let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
assert!(!lock.info().started_at.is_empty());
}
#[test]
fn test_is_process_running_with_our_pid() {
assert!(is_process_running(std::process::id()));
}
#[test]
fn test_is_process_running_with_invalid_pid() {
assert!(!is_process_running(u32::MAX));
}
}