use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub id: String,
pub operation: String,
pub pid: u32,
#[serde(default)]
pub user: Option<String>,
pub project_path: String,
pub acquired_at_unix: u64,
}
#[derive(Debug)]
pub struct LockGuard {
path: PathBuf,
released: bool,
}
impl LockGuard {
#[allow(dead_code)]
pub fn path(&self) -> &Path {
&self.path
}
#[allow(dead_code)]
pub fn release(mut self) -> Result<()> {
self.released = true;
std::fs::remove_file(&self.path)
.with_context(|| format!("removing lock file `{}`", self.path.display()))
}
}
impl Drop for LockGuard {
fn drop(&mut self) {
if self.released {
return;
}
if let Err(e) = std::fs::remove_file(&self.path) {
warn!(
"failed to remove lock file `{}`: {e} — run `clever-project unlock` to clean up",
self.path.display()
);
}
}
}
pub fn lock_path_for(state_path: &Path) -> PathBuf {
let mut s = state_path.as_os_str().to_owned();
s.push(".lock");
PathBuf::from(s)
}
pub fn acquire(state_path: &Path, operation: &str, project_path: &Path) -> Result<LockGuard> {
let path = lock_path_for(state_path);
let id = generate_id();
let info = LockInfo {
id,
operation: operation.to_string(),
pid: std::process::id(),
user: current_user(),
project_path: project_path.display().to_string(),
acquired_at_unix: now_unix(),
};
let body = serde_json::to_string_pretty(&info).context("serializing lock info")?;
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
f.write_all(body.as_bytes())
.with_context(|| format!("writing lock file `{}`", path.display()))?;
debug!("acquired lock `{}` (id={})", path.display(), info.id);
Ok(LockGuard {
path,
released: false,
})
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let existing = peek(&path).unwrap_or(None);
bail!(
"{}",
lock_held_message(&path, existing.as_ref(), project_path)
);
}
Err(e) => Err(e).with_context(|| format!("creating lock file `{}`", path.display()))?,
}
}
pub fn peek(lock_path: &Path) -> Result<Option<LockInfo>> {
if !lock_path.exists() {
return Ok(None);
}
let mut f = File::open(lock_path)
.with_context(|| format!("opening lock file `{}`", lock_path.display()))?;
let mut buf = String::new();
f.read_to_string(&mut buf)
.with_context(|| format!("reading lock file `{}`", lock_path.display()))?;
if buf.trim().is_empty() {
return Ok(None);
}
let info: LockInfo = serde_json::from_str(&buf)
.with_context(|| format!("parsing lock file `{}`", lock_path.display()))?;
Ok(Some(info))
}
pub fn force_remove(lock_path: &Path) -> Result<bool> {
if !lock_path.exists() {
return Ok(false);
}
std::fs::remove_file(lock_path)
.with_context(|| format!("removing lock file `{}`", lock_path.display()))?;
Ok(true)
}
fn lock_held_message(path: &Path, existing: Option<&LockInfo>, project_path: &Path) -> String {
let mut s = format!(
"another `clever-project` run is holding the lock on this project (file: `{}`)",
path.display()
);
if let Some(info) = existing {
let age = age_pretty(info.acquired_at_unix);
let user = info.user.as_deref().unwrap_or("?");
s.push_str(&format!(
"\n operation: {}\n pid: {}\n user: {}\n project: {}\n acquired: {age}\n lock id: {}",
info.operation, info.pid, user, info.project_path, info.id
));
if info.project_path != project_path.display().to_string() {
s.push_str(&format!(
"\n\nnote: the lock was acquired against a different project path (`{}`). If that run finished without cleaning up (Ctrl+C, crash), run `clever-project unlock` against this project to release it.",
info.project_path
));
} else {
s.push_str(
"\n\nif you're sure no run is in progress (Ctrl+C, crash), run `clever-project unlock` to release it.",
);
}
} else {
s.push_str(
"\n(could not read lock metadata — run `clever-project unlock` to release if you're sure no run is in progress)",
);
}
s
}
fn generate_id() -> String {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let secs = now_unix();
format!("{pid:08x}-{secs:x}-{nanos:x}")
}
fn current_user() -> Option<String> {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn age_pretty(acquired_at_unix: u64) -> String {
let now = now_unix();
if now <= acquired_at_unix {
return "just now".to_string();
}
let secs = now - acquired_at_unix;
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m {}s ago", secs / 60, secs % 60)
} else if secs < 86_400 {
format!("{}h {}m ago", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d {}h ago", secs / 86_400, (secs % 86_400) / 3600)
}
}
#[cfg(test)]
mod tests {
use super::*;
static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn tmp_state_path() -> PathBuf {
let seq = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let mut p = std::env::temp_dir();
p.push(format!(
"clever-project-lock-{}-{nanos}-{seq}.state",
std::process::id()
));
p
}
#[test]
fn lock_path_appends_suffix() {
let p = lock_path_for(Path::new("/tmp/foo.state"));
assert_eq!(p, PathBuf::from("/tmp/foo.state.lock"));
}
#[test]
fn acquire_creates_file_and_drop_releases_it() {
let state = tmp_state_path();
let lock = lock_path_for(&state);
{
let _guard = acquire(&state, "apply", Path::new("/tmp/proj.yaml")).unwrap();
assert!(lock.exists(), "lock file should exist while guard is alive");
}
assert!(!lock.exists(), "lock file should be gone after drop");
}
#[test]
fn second_acquire_fails_with_holder_metadata() {
let state = tmp_state_path();
let lock = lock_path_for(&state);
let _g1 = acquire(&state, "apply", Path::new("/tmp/proj.yaml")).unwrap();
let err = acquire(&state, "apply", Path::new("/tmp/proj.yaml")).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("holding the lock"), "got: {msg}");
assert!(msg.contains("operation:"), "got: {msg}");
assert!(msg.contains("pid:"), "got: {msg}");
drop(_g1);
let _ = std::fs::remove_file(&lock);
}
#[test]
fn explicit_release_removes_lock() {
let state = tmp_state_path();
let lock = lock_path_for(&state);
let g = acquire(&state, "apply", Path::new("/tmp/proj.yaml")).unwrap();
g.release().unwrap();
assert!(!lock.exists());
}
#[test]
fn peek_returns_info_for_held_lock() {
let state = tmp_state_path();
let lock = lock_path_for(&state);
let _g = acquire(&state, "delete", Path::new("/tmp/p.yaml")).unwrap();
let info = peek(&lock).unwrap().expect("peek should return info");
assert_eq!(info.operation, "delete");
assert_eq!(info.project_path, "/tmp/p.yaml");
assert_eq!(info.pid, std::process::id());
}
#[test]
fn peek_returns_none_when_absent() {
let state = tmp_state_path();
let lock = lock_path_for(&state);
assert!(peek(&lock).unwrap().is_none());
}
#[test]
fn force_remove_clears_lock() {
let state = tmp_state_path();
let lock = lock_path_for(&state);
std::mem::forget(acquire(&state, "apply", Path::new("/tmp/p.yaml")).unwrap());
assert!(lock.exists());
let removed = force_remove(&lock).unwrap();
assert!(removed);
assert!(!lock.exists());
let removed_again = force_remove(&lock).unwrap();
assert!(!removed_again);
}
#[test]
fn age_pretty_formats() {
let now = now_unix();
assert_eq!(age_pretty(now), "just now");
assert!(age_pretty(now.saturating_sub(10)).ends_with("s ago"));
assert!(age_pretty(now.saturating_sub(120)).contains('m'));
assert!(age_pretty(now.saturating_sub(7_200)).contains('h'));
assert!(age_pretty(now.saturating_sub(200_000)).contains('d'));
}
}