1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13
14#[derive(Debug, Clone)]
16pub struct FileClaim {
17 pub owner_id: String,
19 pub owner_label: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ClaimResult {
26 Acquired,
28 Held,
30 Conflict {
32 owner_id: String,
33 owner_label: String,
34 },
35}
36
37#[derive(Debug, Clone)]
39pub struct FileConflictEvent {
40 pub path: String,
41 pub claimant_id: String,
42 pub claimant_label: String,
43 pub owner_id: String,
44 pub owner_label: String,
45}
46
47pub type ConflictListener = Arc<dyn Fn(FileConflictEvent) + Send + Sync>;
49
50#[derive(Clone, Default)]
52pub struct FileClaimRegistry {
53 inner: Arc<FileClaimRegistryInner>,
54}
55
56#[derive(Debug, Clone)]
58struct WriteRecord {
59 owner_id: String,
60 owner_label: String,
61 generation: u64,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct FileStaleEvent {
67 pub path: String,
68 pub reader_id: String,
69 pub writer_id: String,
70 pub writer_label: String,
71}
72
73pub type StaleListener = Arc<dyn Fn(FileStaleEvent) + Send + Sync>;
75
76struct FileClaimRegistryInner {
77 claims: Mutex<HashMap<String, FileClaim>>,
79 listener: Mutex<Option<ConflictListener>>,
80 last_write: Mutex<HashMap<String, WriteRecord>>,
81 last_seen: Mutex<HashMap<(String, String), u64>>,
83 stale_listener: Mutex<Option<StaleListener>>,
84}
85
86impl Default for FileClaimRegistryInner {
87 fn default() -> Self {
88 Self {
89 claims: Mutex::new(HashMap::new()),
90 listener: Mutex::new(None),
91 last_write: Mutex::new(HashMap::new()),
92 last_seen: Mutex::new(HashMap::new()),
93 stale_listener: Mutex::new(None),
94 }
95 }
96}
97
98impl std::fmt::Debug for FileClaimRegistry {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 let n = recover_lock(self.inner.claims.lock()).len();
101 f.debug_struct("FileClaimRegistry")
102 .field("claims", &n)
103 .finish()
104 }
105}
106
107impl FileClaimRegistry {
108 pub fn new() -> Self {
109 Self::default()
110 }
111
112 pub fn set_listener(&self, listener: Option<ConflictListener>) {
114 *recover_lock(self.inner.listener.lock()) = listener;
115 }
116
117 pub fn claim_key(path: &Path) -> String {
119 let abs = if path.is_absolute() {
120 path.to_path_buf()
121 } else {
122 path_or_dot(std::env::current_dir()).join(path)
123 };
124 let normalized = abs.canonicalize().unwrap_or_else(|_| {
126 let mut out = PathBuf::new();
128 for comp in abs.components() {
129 apply_component(&mut out, comp);
130 }
131 out
132 });
133 normalized.to_string_lossy().into_owned()
134 }
135
136 pub fn try_claim(&self, agent_id: &str, agent_label: &str, path: &Path) -> ClaimResult {
138 let key = Self::claim_key(path);
139 let mut map = recover_lock(self.inner.claims.lock());
140 match map.get(&key) {
141 Some(c) if c.owner_id == agent_id => {
142 self.record_write(&key, agent_id, agent_label);
143 ClaimResult::Held
144 }
145 Some(c) => {
146 let ev = FileConflictEvent {
147 path: key,
148 claimant_id: agent_id.to_string(),
149 claimant_label: agent_label.to_string(),
150 owner_id: c.owner_id.clone(),
151 owner_label: c.owner_label.clone(),
152 };
153 self.emit_conflict(ev.clone());
154 ClaimResult::Conflict {
155 owner_id: ev.owner_id,
156 owner_label: ev.owner_label,
157 }
158 }
159 None => {
160 map.insert(
161 key.clone(),
162 FileClaim {
163 owner_id: agent_id.to_string(),
164 owner_label: agent_label.to_string(),
165 },
166 );
167 self.record_write(&key, agent_id, agent_label);
168 ClaimResult::Acquired
169 }
170 }
171 }
172
173 fn record_write(&self, key: &str, agent_id: &str, agent_label: &str) {
174 let mut writes = recover_lock(self.inner.last_write.lock());
175 let generation = writes
176 .get(key)
177 .map(|w| w.generation)
178 .unwrap_or(0)
179 .saturating_add(1);
180 writes.insert(
181 key.to_string(),
182 WriteRecord {
183 owner_id: agent_id.to_string(),
184 owner_label: agent_label.to_string(),
185 generation,
186 },
187 );
188 recover_lock(self.inner.last_seen.lock())
189 .insert((agent_id.to_string(), key.to_string()), generation);
190 }
191
192 pub fn set_stale_listener(&self, listener: Option<StaleListener>) {
193 *recover_lock(self.inner.stale_listener.lock()) = listener;
194 }
195
196 pub fn note_read(&self, agent_id: &str, path: &Path) -> Option<FileStaleEvent> {
199 let key = Self::claim_key(path);
200 let writes = recover_lock(self.inner.last_write.lock());
201 let write = writes.get(&key)?;
202 if write.owner_id == agent_id {
203 return None;
204 }
205 let generation = write.generation;
206 let writer_id = write.owner_id.clone();
207 let writer_label = write.owner_label.clone();
208 drop(writes);
209
210 let mut seen = recover_lock(self.inner.last_seen.lock());
211 let last = seen.get(&(agent_id.to_string(), key.clone())).copied();
212 seen.insert((agent_id.to_string(), key.clone()), generation);
213 if last == Some(generation) {
214 return None;
215 }
216 let ev = FileStaleEvent {
217 path: key,
218 reader_id: agent_id.to_string(),
219 writer_id,
220 writer_label,
221 };
222 if let Some(ref f) = *recover_lock(self.inner.stale_listener.lock()) {
223 f(ev.clone());
224 }
225 Some(ev)
226 }
227
228 fn emit_conflict(&self, ev: FileConflictEvent) {
229 if let Some(ref f) = *recover_lock(self.inner.listener.lock()) {
230 f(ev);
231 }
232 }
233
234 pub fn release_agent(&self, agent_id: &str) {
236 let mut map = recover_lock(self.inner.claims.lock());
237 map.retain(|_, c| c.owner_id != agent_id);
238 }
239
240 pub fn clear(&self) {
242 recover_lock(self.inner.claims.lock()).clear();
243 }
244
245 pub fn snapshot(&self) -> Vec<(String, String)> {
247 let map = recover_lock(self.inner.claims.lock());
248 let mut rows: Vec<_> = map
249 .iter()
250 .map(|(p, c)| (p.clone(), c.owner_label.clone()))
251 .collect();
252 rows.sort_by(|a, b| a.0.cmp(&b.0));
253 rows
254 }
255
256 pub fn len(&self) -> usize {
257 recover_lock(self.inner.claims.lock()).len()
258 }
259
260 pub fn is_empty(&self) -> bool {
261 self.len() == 0
262 }
263}
264
265pub(crate) fn apply_component(out: &mut PathBuf, comp: std::path::Component<'_>) {
266 match comp {
267 std::path::Component::ParentDir => {
268 out.pop();
269 }
270 std::path::Component::CurDir => {}
271 other => out.push(other.as_os_str()),
272 }
273}
274
275pub(crate) fn path_or_dot(res: std::io::Result<PathBuf>) -> PathBuf {
276 res.unwrap_or_else(|_| PathBuf::from("."))
277}
278
279pub(crate) fn recover_lock<T>(res: std::sync::LockResult<T>) -> T {
280 match res {
281 Ok(g) => g,
282 Err(p) => p.into_inner(),
283 }
284}
285
286#[cfg(test)]
287impl FileClaimRegistry {
288 pub(crate) fn poison_claims(&self) {
289 poison_mutex(&self.inner.claims);
290 }
291 pub(crate) fn poison_last_write(&self) {
292 poison_mutex(&self.inner.last_write);
293 }
294 pub(crate) fn poison_last_seen(&self) {
295 poison_mutex(&self.inner.last_seen);
296 }
297 pub(crate) fn poison_listener(&self) {
298 poison_mutex(&self.inner.listener);
299 }
300 pub(crate) fn poison_stale_listener(&self) {
301 poison_mutex(&self.inner.stale_listener);
302 }
303}
304
305#[cfg(test)]
306fn poison_mutex<T>(m: &Mutex<T>) {
307 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
308 let _g = m.lock().unwrap();
309 panic!("poison");
310 }));
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use std::path::Path;
317 use std::sync::{Arc, Mutex};
318
319 #[test]
320 fn claim_hold_conflict_stale_and_snapshot() {
321 let reg = FileClaimRegistry::new();
322 assert!(reg.is_empty());
323 assert_eq!(reg.len(), 0);
324 let a = Path::new("src/a.rs");
325 assert_eq!(reg.try_claim("w0", "worker-0", a), ClaimResult::Acquired);
326 assert_eq!(reg.try_claim("w0", "worker-0", a), ClaimResult::Held);
327 assert_eq!(
328 reg.try_claim("w1", "worker-1", a),
329 ClaimResult::Conflict {
330 owner_id: "w0".into(),
331 owner_label: "worker-0".into(),
332 }
333 );
334 let seen = Arc::new(Mutex::new(Vec::new()));
335 let seen2 = Arc::clone(&seen);
336 reg.set_listener(Some(Arc::new(move |ev: FileConflictEvent| {
337 seen2.lock().unwrap().push(ev.owner_id);
338 })));
339 let _ = reg.try_claim("w1", "worker-1", a);
340 assert!(!seen.lock().unwrap().is_empty());
341
342 let stale = Arc::new(Mutex::new(0usize));
343 let stale2 = Arc::clone(&stale);
344 reg.set_stale_listener(Some(Arc::new(move |_ev: FileStaleEvent| {
345 *stale2.lock().unwrap() += 1;
346 })));
347 assert!(reg.note_read("w0", a).is_none());
348 assert!(reg.note_read("w1", a).is_some());
349 assert_eq!(*stale.lock().unwrap(), 1);
350 assert!(reg.note_read("w1", a).is_none());
351 assert_eq!(*stale.lock().unwrap(), 1);
352 assert!(format!("{reg:?}").contains("FileClaimRegistry"));
353 assert!(!reg.snapshot().is_empty());
354 assert!(!FileClaimRegistry::claim_key(a).is_empty());
355 reg.release_agent("w0");
356 reg.clear();
357 assert!(reg.is_empty());
358 }
359}