use std::{
fs::{self, File, OpenOptions},
path::{Path, PathBuf},
};
use crate::error::{LockError, LockResult};
pub struct FileLock {
_file: nix::fcntl::Flock<File>,
path: PathBuf,
}
impl FileLock {
fn lock_dir() -> LockResult<PathBuf> {
let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok();
let base = if let Some(ref runtime) = xdg_runtime {
PathBuf::from(runtime)
} else {
std::env::temp_dir()
};
let lock_dir = base.join("soar").join("locks");
if !lock_dir.exists() {
fs::create_dir_all(&lock_dir)?;
}
Ok(lock_dir)
}
fn lock_path(name: &str) -> LockResult<PathBuf> {
let lock_dir = Self::lock_dir()?;
let sanitize = |s: &str| {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect::<String>()
};
let filename = format!("{}.lock", sanitize(name));
Ok(lock_dir.join(filename))
}
pub fn acquire(name: &str) -> LockResult<Self> {
let lock_path = Self::lock_path(name)?;
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
let file = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive).map_err(
|(_, err)| LockError::AcquireFailed(format!("{}: {}", lock_path.display(), err)),
)?;
Ok(FileLock {
path: lock_path,
_file: file,
})
}
pub fn try_acquire(name: &str) -> LockResult<Option<Self>> {
let lock_path = Self::lock_path(name)?;
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
Ok(file) => {
Ok(Some(FileLock {
path: lock_path,
_file: file,
}))
}
Err((_, err)) => {
if matches!(err, nix::errno::Errno::EWOULDBLOCK) {
return Ok(None);
}
Err(LockError::AcquireFailed(format!(
"{}: {}",
lock_path.display(),
err
)))
}
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use std::{thread, time::Duration};
use super::*;
#[test]
fn test_lock_path_generation() {
let path = FileLock::lock_path("test-pkg").unwrap();
assert!(path.to_string_lossy().ends_with("test-pkg.lock"));
}
#[test]
fn test_lock_sanitization() {
let path = FileLock::lock_path("test/pkg").unwrap();
assert!(path.to_string_lossy().contains("test_pkg"));
}
#[test]
fn test_exclusive_lock() {
let lock1 = FileLock::acquire("test-exclusive").unwrap();
let lock2 = FileLock::try_acquire("test-exclusive").unwrap();
assert!(lock2.is_none(), "Should not be able to acquire lock");
drop(lock1);
let lock3 = FileLock::try_acquire("test-exclusive").unwrap();
assert!(
lock3.is_some(),
"Should be able to acquire lock after release"
);
}
#[test]
fn test_concurrent_locks_different_packages() {
let lock1 = FileLock::acquire("pkg-a").unwrap();
let lock2 = FileLock::acquire("pkg-b").unwrap();
assert!(lock1.path() != lock2.path());
}
#[test]
fn test_lock_blocks_until_released() {
let lock1 = FileLock::acquire("test-block").unwrap();
let path = lock1.path().to_path_buf();
let handle = thread::spawn(move || {
let lock2 = FileLock::acquire("test-block").unwrap();
assert_eq!(lock2.path(), &path);
});
thread::sleep(Duration::from_millis(100));
drop(lock1);
handle.join().unwrap();
}
}