1use std::io::Write;
26use std::path::{Path, PathBuf};
27
28use camino::Utf8Path;
29
30use crate::applog;
31use crate::diagnostic::{Diagnostic, Reason};
32use crate::digest::Digest;
33use crate::error::RkError;
34
35pub const LOCKS_DIR: &str = "locks";
37
38#[derive(Debug)]
48pub struct TargetLock {
49 path: PathBuf,
50}
51
52impl TargetLock {
53 #[must_use]
55 pub fn path(&self) -> &Path {
56 &self.path
57 }
58}
59
60impl Drop for TargetLock {
61 fn drop(&mut self) {
62 let _ = std::fs::remove_file(&self.path);
63 }
64}
65
66pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
82 let Some(root) = applog::state_root() else {
83 return Err(rootless());
84 };
85 acquire_in(&root.join(LOCKS_DIR), target)
86}
87
88fn rootless() -> RkError {
90 RkError::refusal(
91 Diagnostic::new(
92 Reason::PrerequisiteUnmet,
93 "no state root resolves, so this run cannot take the target, and nothing was written",
94 )
95 .expected("one apply against a target at a time, held by a lock under the state root")
96 .action("set XDG_STATE_HOME, or HOME, and run it again")
97 .target_state("unchanged"),
98 )
99}
100
101pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
111 let canonical = std::fs::canonicalize(target)
112 .map_or_else(|_| target.to_string(), |path| path.display().to_string());
113 std::fs::create_dir_all(dir)?;
114 let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
115 match std::fs::OpenOptions::new()
116 .write(true)
117 .create_new(true)
118 .open(&path)
119 {
120 Ok(mut file) => {
121 let _ = writeln!(file, "{}", std::process::id());
124 let _ = writeln!(file, "{canonical}");
125 Ok(TargetLock { path })
126 }
127 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
128 Err(busy(&canonical, &path))
129 }
130 Err(error) => Err(RkError::Io(error)),
131 }
132}
133
134fn busy(target: &str, path: &Path) -> RkError {
136 let holder = std::fs::read_to_string(path)
137 .ok()
138 .and_then(|text| text.lines().next().map(str::to_owned))
139 .map_or_else(
140 || "another run".to_owned(),
141 |pid| format!("the run at process {pid}"),
142 );
143 RkError::refusal(
144 Diagnostic::new(
145 Reason::TargetBusy,
146 format!("{holder} holds {target}, and nothing was written"),
147 )
148 .expected("one apply against a target at a time")
149 .action(format!(
150 "wait for that run to finish; where it is gone, remove {}",
151 path.display()
152 ))
153 .target_state("unchanged"),
154 )
155}
156
157#[cfg(test)]
158mod tests {
159 use super::{acquire_in, busy, rootless};
160 use crate::diagnostic::Reason;
161
162 fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
163 camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
164 }
165
166 #[test]
169 fn one_run_holds_a_target_at_a_time() {
170 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
171 let target = tempfile::tempdir().expect("a scratch target exists");
172 let path = utf8(&target);
173 let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
174 let second = acquire_in(locks.path(), &path);
175 assert_eq!(
176 second.expect_err("the second refuses").reason(),
177 Reason::TargetBusy
178 );
179 drop(first);
180 acquire_in(locks.path(), &path).expect("the target is free again");
181 }
182
183 #[test]
185 fn two_targets_are_two_locks() {
186 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
187 let a = tempfile::tempdir().expect("a scratch target exists");
188 let b = tempfile::tempdir().expect("a second scratch target exists");
189 let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
190 acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
191 }
192
193 #[test]
196 fn a_host_with_no_state_root_refuses() {
197 let error = rootless();
198 assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
199 let diagnostic = error.diagnostic();
200 assert!(
201 diagnostic
202 .action
203 .unwrap_or_default()
204 .contains("XDG_STATE_HOME"),
205 "{:?}",
206 diagnostic.message
207 );
208 assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
209 }
210
211 #[test]
214 fn the_refusal_names_the_lock_file() {
215 let dir = tempfile::tempdir().expect("a scratch directory exists");
216 let path = dir.path().join("held.lock");
217 std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
218 let diagnostic = busy("/some/target", &path).diagnostic();
219 assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
220 let action = diagnostic.action.unwrap_or_default();
221 assert!(action.contains("held.lock"), "{action}");
222 }
223}