use std::{
fs::{self, File, OpenOptions, TryLockError},
path::{Path, PathBuf},
time::{Duration, Instant},
};
use anyhow::Context as _;
use sha2::{Digest, Sha256};
use crate::config::Config;
#[derive(Debug)]
pub struct FileLock {
_file: File,
}
impl FileLock {
fn open(path: &Path) -> anyhow::Result<File> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating lock dir {}", parent.display()))?;
}
OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
.with_context(|| format!("opening lock file {}", path.display()))
}
pub fn try_acquire(path: &Path) -> anyhow::Result<Option<FileLock>> {
let file = Self::open(path)?;
match file.try_lock() {
Ok(()) => Ok(Some(FileLock { _file: file })),
Err(TryLockError::WouldBlock) => Ok(None),
Err(TryLockError::Error(err)) => {
Err(anyhow::Error::from(err).context(format!("try-locking {}", path.display())))
},
}
}
pub fn acquire_blocking(path: &Path) -> anyhow::Result<FileLock> {
let file = Self::open(path)?;
file.lock().with_context(|| format!("locking {}", path.display()))?;
Ok(FileLock { _file: file })
}
pub fn acquire_timeout(path: &Path, timeout: Duration) -> anyhow::Result<Option<FileLock>> {
let deadline = Instant::now() + timeout;
let poll = Duration::from_millis(50).min(timeout.max(Duration::from_millis(1)));
loop {
if let Some(lock) = Self::try_acquire(path)? {
return Ok(Some(lock));
}
if Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(poll);
}
}
}
pub fn write_lock_path(database: &Path) -> PathBuf {
database.parent().unwrap_or_else(|| Path::new(".")).join("rag-rat-write.lock")
}
pub const MAX_SOCKET_PATH_LEN: usize = 100;
fn worktree_hash(worktree_root: &Path) -> String {
let canonical = worktree_root.canonicalize().unwrap_or_else(|_| worktree_root.to_path_buf());
let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
let mut hash = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(hash, "{byte:02x}");
}
hash
}
pub fn election_lock_path(base_dir: &Path, worktree_root: &Path) -> PathBuf {
base_dir.join("locks").join(format!("{}.lock", worktree_hash(worktree_root)))
}
pub fn socket_lock_path(base_dir: &Path, worktree_root: &Path) -> PathBuf {
base_dir.join("locks").join(format!("{}.socket.lock", worktree_hash(worktree_root)))
}
pub fn hook_socket_path(base_dir: &Path, worktree_root: &Path) -> PathBuf {
let runtime_base =
std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
socket_path_with_runtime_base(base_dir, worktree_root, &runtime_base)
}
pub fn hook_socket_path_for(config: &Config) -> PathBuf {
let base =
config.database.parent().map(Path::to_path_buf).unwrap_or_else(|| config.root.clone());
hook_socket_path(&base, &config.root)
}
pub fn hook_socket_lock_path_for(config: &Config) -> PathBuf {
let base =
config.database.parent().map(Path::to_path_buf).unwrap_or_else(|| config.root.clone());
socket_lock_path(&base, &config.root)
}
fn socket_path_with_runtime_base(
base_dir: &Path,
worktree_root: &Path,
runtime_base: &Path,
) -> PathBuf {
let name = format!("{}.sock", worktree_hash(worktree_root));
let preferred = base_dir.join("sockets").join(&name);
if preferred.as_os_str().len() <= MAX_SOCKET_PATH_LEN {
return preferred;
}
let xdg_candidate = runtime_base.join("rag-rat").join(&name);
if xdg_candidate.as_os_str().len() <= MAX_SOCKET_PATH_LEN {
return xdg_candidate;
}
std::env::temp_dir().join("rag-rat").join(name)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
static LOCK_TEMP: AtomicU64 = AtomicU64::new(0);
fn temp_dir() -> PathBuf {
let id = LOCK_TEMP.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ragrat-lock-{}-{id}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn exclusive_lock_blocks_second_holder_and_releases_on_drop() {
let dir = temp_dir();
let path = dir.join("a.lock");
let first = FileLock::try_acquire(&path).unwrap();
assert!(first.is_some(), "first acquire should succeed");
let second = FileLock::try_acquire(&path).unwrap();
assert!(second.is_none(), "second acquire must fail while held");
let other = FileLock::try_acquire(&dir.join("b.lock")).unwrap();
assert!(other.is_some(), "a different lock path should acquire");
drop(first);
let reacquired = FileLock::try_acquire(&path).unwrap();
assert!(reacquired.is_some(), "should acquire after the holder drops");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn election_path_is_stable_per_root_and_distinct_across_roots() {
let base = Path::new("/repo/.git/rag-rat");
let a1 = election_lock_path(base, Path::new("/repo"));
let a2 = election_lock_path(base, Path::new("/repo"));
let b = election_lock_path(base, Path::new("/repo-wt"));
assert_eq!(a1, a2, "same worktree root → same lock");
assert_ne!(a1, b, "different worktree roots → different locks");
assert!(a1.starts_with(base.join("locks")));
}
#[test]
fn socket_lock_path_is_distinct_from_election_lock_path() {
let base = temp_dir();
let root = temp_dir();
let election = election_lock_path(&base, &root);
let socket_lock = socket_lock_path(&base, &root);
assert_ne!(election, socket_lock);
assert!(socket_lock.to_string_lossy().ends_with(".socket.lock"));
assert_eq!(election.parent(), socket_lock.parent());
}
#[test]
fn hook_socket_path_lives_under_base_sockets_dir() {
let base = temp_dir();
let root = temp_dir();
let socket = hook_socket_path(&base, &root);
assert_eq!(socket.parent().unwrap().file_name().unwrap(), "sockets");
assert!(socket.extension().is_some_and(|ext| ext == "sock"));
}
fn long_base_dir() -> PathBuf {
let mut base = temp_dir();
for _ in 0..12 {
base.push("very-long-directory-segment");
}
base
}
#[test]
fn hook_socket_path_falls_back_when_base_path_is_too_long() {
let long_base = long_base_dir();
let root = temp_dir();
let short_runtime_base = std::env::temp_dir(); let socket = socket_path_with_runtime_base(&long_base, &root, &short_runtime_base);
assert!(
socket.as_os_str().len() <= MAX_SOCKET_PATH_LEN,
"XDG fallback path still too long: {}",
socket.display()
);
assert!(!socket.starts_with(&long_base), "expected fallback, got preferred path");
}
#[test]
fn hook_socket_path_falls_back_to_temp_when_xdg_also_too_long() {
let long_base = long_base_dir();
let long_runtime_base = long_base_dir();
let root = temp_dir();
let socket = socket_path_with_runtime_base(&long_base, &root, &long_runtime_base);
assert!(!socket.starts_with(&long_base));
assert!(!socket.starts_with(&long_runtime_base));
assert!(
socket.starts_with(std::env::temp_dir()),
"expected temp-dir fallback, got: {}",
socket.display()
);
}
}