use std::io::Write;
use std::path::{Path, PathBuf};
use camino::Utf8Path;
use crate::applog;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
pub const LOCKS_DIR: &str = "locks";
#[derive(Debug)]
pub struct TargetLock {
path: PathBuf,
}
impl TargetLock {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TargetLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
let Some(root) = applog::state_root() else {
return Err(rootless());
};
acquire_in(&root.join(LOCKS_DIR), target)
}
fn rootless() -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"no state root resolves, so this run cannot take the target, and nothing was written",
)
.expected("one apply against a target at a time, held by a lock under the state root")
.action("set XDG_STATE_HOME, or HOME, and run it again")
.target_state("unchanged"),
)
}
pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
let canonical = std::fs::canonicalize(target)
.map_or_else(|_| target.to_string(), |path| path.display().to_string());
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
let _ = writeln!(file, "{}", std::process::id());
let _ = writeln!(file, "{canonical}");
Ok(TargetLock { path })
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(busy(&canonical, &path))
}
Err(error) => Err(RkError::Io(error)),
}
}
fn busy(target: &str, path: &Path) -> RkError {
let holder = std::fs::read_to_string(path)
.ok()
.and_then(|text| text.lines().next().map(str::to_owned))
.map_or_else(
|| "another run".to_owned(),
|pid| format!("the run at process {pid}"),
);
RkError::refusal(
Diagnostic::new(
Reason::TargetBusy,
format!("{holder} holds {target}, and nothing was written"),
)
.expected("one apply against a target at a time")
.action(format!(
"wait for that run to finish; where it is gone, remove {}",
path.display()
))
.target_state("unchanged"),
)
}
#[cfg(test)]
mod tests {
use super::{acquire_in, busy, rootless};
use crate::diagnostic::Reason;
fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
}
#[test]
fn one_run_holds_a_target_at_a_time() {
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let target = tempfile::tempdir().expect("a scratch target exists");
let path = utf8(&target);
let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
let second = acquire_in(locks.path(), &path);
assert_eq!(
second.expect_err("the second refuses").reason(),
Reason::TargetBusy
);
drop(first);
acquire_in(locks.path(), &path).expect("the target is free again");
}
#[test]
fn two_targets_are_two_locks() {
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let a = tempfile::tempdir().expect("a scratch target exists");
let b = tempfile::tempdir().expect("a second scratch target exists");
let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
}
#[test]
fn a_host_with_no_state_root_refuses() {
let error = rootless();
assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
let diagnostic = error.diagnostic();
assert!(
diagnostic
.action
.unwrap_or_default()
.contains("XDG_STATE_HOME"),
"{:?}",
diagnostic.message
);
assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
}
#[test]
fn the_refusal_names_the_lock_file() {
let dir = tempfile::tempdir().expect("a scratch directory exists");
let path = dir.path().join("held.lock");
std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
let diagnostic = busy("/some/target", &path).diagnostic();
assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
let action = diagnostic.action.unwrap_or_default();
assert!(action.contains("held.lock"), "{action}");
}
}