1use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct RegisteredRoot {
25 pub project_root: PathBuf,
27 pub phase: u32,
29 pub registered_at: String,
32}
33
34#[derive(Debug, thiserror::Error)]
36pub enum RegistryError {
37 #[error("registry I/O failed: {0}")]
39 Io(#[from] std::io::Error),
40 #[error("registry JSON failed: {0}")]
42 Json(#[from] serde_json::Error),
43}
44
45pub fn cache_dir() -> Option<PathBuf> {
53 if let Some(dir) = std::env::var_os("DEVFLOW_CACHE_DIR") {
54 return Some(PathBuf::from(dir));
55 }
56 if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
57 return Some(PathBuf::from(dir).join("devflow"));
58 }
59 let home = std::env::var_os("HOME")?;
60 Some(PathBuf::from(home).join(".cache").join("devflow"))
61}
62
63pub fn roots_dir_in(cache_dir: &Path) -> PathBuf {
66 cache_dir.join("roots")
67}
68
69pub fn entry_path_in(cache_dir: &Path, project_root: &Path, phase: u32) -> PathBuf {
75 let digest = path_digest(project_root);
76 roots_dir_in(cache_dir).join(format!("{digest:016x}-{phase:02}.json"))
77}
78
79fn path_digest(path: &Path) -> u64 {
85 use std::os::unix::ffi::OsStrExt;
86 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
87 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
88 let mut hash = FNV_OFFSET_BASIS;
89 for byte in path.as_os_str().as_bytes() {
90 hash ^= u64::from(*byte);
91 hash = hash.wrapping_mul(FNV_PRIME);
92 }
93 hash
94}
95
96pub fn register_in(cache_dir: &Path, project_root: &Path, phase: u32) -> Result<(), RegistryError> {
111 ensure_private_dir(cache_dir)?;
112 let dir = roots_dir_in(cache_dir);
113 ensure_private_dir(&dir)?;
114 let entry = RegisteredRoot {
115 project_root: project_root.to_path_buf(),
116 phase,
117 registered_at: unix_now(),
118 };
119 let path = entry_path_in(cache_dir, project_root, phase);
120 write_atomic(&path, &serde_json::to_string_pretty(&entry)?)?;
121 Ok(())
122}
123
124pub fn prune_missing_in(cache_dir: &Path) -> usize {
133 let mut removed = 0;
134 let dir = roots_dir_in(cache_dir);
135 let Ok(entries) = std::fs::read_dir(&dir) else {
136 return 0;
137 };
138 for entry in entries.flatten() {
139 let name = entry.file_name();
140 let Some(name) = name.to_str() else { continue };
141 if !name.ends_with(".json") {
142 continue;
143 }
144 let path = entry.path();
145 let root_still_exists = std::fs::read_to_string(&path)
146 .ok()
147 .and_then(|contents| serde_json::from_str::<RegisteredRoot>(&contents).ok())
148 .is_some_and(|root| root.project_root.is_dir());
149 if !root_still_exists && std::fs::remove_file(&path).is_ok() {
150 removed += 1;
151 }
152 }
153 removed
154}
155
156pub fn prune_missing() -> usize {
159 let Some(dir) = cache_dir() else {
160 return 0;
161 };
162 prune_missing_in(&dir)
163}
164
165pub fn deregister_in(
172 cache_dir: &Path,
173 project_root: &Path,
174 phase: u32,
175) -> Result<(), RegistryError> {
176 let path = entry_path_in(cache_dir, project_root, phase);
177 match std::fs::remove_file(path) {
178 Ok(()) => Ok(()),
179 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
180 Err(err) => Err(err.into()),
181 }
182}
183
184pub fn deregister(project_root: &Path, phase: u32) {
189 let Some(dir) = cache_dir() else {
190 return;
191 };
192 let _ = deregister_in(&dir, project_root, phase);
193}
194
195fn ensure_private_dir(dir: &Path) -> Result<(), RegistryError> {
200 use std::os::unix::fs::PermissionsExt;
201 std::fs::create_dir_all(dir)?;
202 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
203 Ok(())
204}
205
206fn write_atomic(path: &Path, contents: &str) -> Result<(), RegistryError> {
215 static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
216 let n = TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
217 let tmp = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
218 std::fs::write(&tmp, contents)?;
219 std::fs::rename(&tmp, path)?;
220 Ok(())
221}
222
223pub fn load_roots_in(cache_dir: &Path) -> Vec<RegisteredRoot> {
230 let mut roots = Vec::new();
231 let dir = roots_dir_in(cache_dir);
232 let Ok(entries) = std::fs::read_dir(&dir) else {
233 return roots;
234 };
235 for entry in entries.flatten() {
236 let name = entry.file_name();
237 let Some(name) = name.to_str() else { continue };
238 if !name.ends_with(".json") {
239 continue;
240 }
241 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
242 continue;
243 };
244 let Ok(root) = serde_json::from_str::<RegisteredRoot>(&contents) else {
245 continue;
246 };
247 roots.push(root);
248 }
249 roots.sort_by(|a, b| (&a.project_root, a.phase).cmp(&(&b.project_root, b.phase)));
250 roots
251}
252
253pub fn register(project_root: &Path, phase: u32) -> Result<(), RegistryError> {
258 let Some(dir) = cache_dir() else {
259 return Ok(());
260 };
261 register_in(&dir, project_root, phase)
262}
263
264pub fn load_roots() -> Vec<RegisteredRoot> {
267 let Some(dir) = cache_dir() else {
268 return Vec::new();
269 };
270 load_roots_in(&dir)
271}
272
273fn unix_now() -> String {
274 std::time::SystemTime::now()
275 .duration_since(std::time::UNIX_EPOCH)
276 .map(|d| d.as_secs().to_string())
277 .unwrap_or_else(|_| "0".to_string())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn register_in_two_different_pairs_both_survive_and_load_sorted() {
286 let dir = tempfile::tempdir().unwrap();
287 let cache = dir.path();
288 let root_a = PathBuf::from("/tmp/project-a");
289 let root_b = PathBuf::from("/tmp/project-b");
290
291 register_in(cache, &root_a, 5).unwrap();
292 register_in(cache, &root_b, 7).unwrap();
293
294 let roots = load_roots_in(cache);
295 assert_eq!(roots.len(), 2);
296 assert!(
297 roots
298 .iter()
299 .any(|r| r.project_root == root_a && r.phase == 5)
300 );
301 assert!(
302 roots
303 .iter()
304 .any(|r| r.project_root == root_b && r.phase == 7)
305 );
306 assert!(roots[0].project_root <= roots[1].project_root);
308 }
309
310 #[test]
311 fn register_in_same_root_two_phases_survive_as_distinct_files() {
312 let dir = tempfile::tempdir().unwrap();
313 let cache = dir.path();
314 let root = PathBuf::from("/tmp/project-multi-phase");
315
316 register_in(cache, &root, 1).unwrap();
317 register_in(cache, &root, 2).unwrap();
318
319 let roots = load_roots_in(cache);
320 assert_eq!(roots.len(), 2);
321 assert!(roots.iter().any(|r| r.phase == 1));
322 assert!(roots.iter().any(|r| r.phase == 2));
323 }
324
325 #[test]
326 fn load_roots_in_skips_one_corrupt_entry_and_keeps_its_sibling() {
327 let dir = tempfile::tempdir().unwrap();
328 let cache = dir.path();
329 let root = PathBuf::from("/tmp/project-good");
330 register_in(cache, &root, 3).unwrap();
331
332 let junk_path = roots_dir_in(cache).join("junk-entry.json");
333 std::fs::write(&junk_path, "{not json").unwrap();
334
335 let roots = load_roots_in(cache);
336 assert_eq!(roots.len(), 1);
337 assert_eq!(roots[0].project_root, root);
338 assert_eq!(roots[0].phase, 3);
339 }
340
341 #[test]
342 fn load_roots_in_on_absent_directory_returns_empty_without_panicking() {
343 let dir = tempfile::tempdir().unwrap();
344 let cache = dir.path().join("never-created");
345 assert!(load_roots_in(&cache).is_empty());
346 }
347
348 #[test]
349 fn register_in_same_pair_twice_results_in_exactly_one_entry() {
350 let dir = tempfile::tempdir().unwrap();
351 let cache = dir.path();
352 let root = PathBuf::from("/tmp/project-reregister");
353
354 register_in(cache, &root, 9).unwrap();
355 register_in(cache, &root, 9).unwrap();
356
357 let roots = load_roots_in(cache);
358 assert_eq!(roots.len(), 1);
359 }
360
361 #[test]
366 fn concurrent_registration_of_different_pairs_both_survive() {
367 let cache = tempfile::tempdir().unwrap();
368 let cache_path = cache.path().to_path_buf();
369 let root_a = PathBuf::from("/tmp/concurrent-project-a");
370 let root_b = PathBuf::from("/tmp/concurrent-project-b");
371
372 std::thread::scope(|scope| {
373 let a = scope.spawn(|| register_in(&cache_path, &root_a, 1));
374 let b = scope.spawn(|| register_in(&cache_path, &root_b, 1));
375 a.join().unwrap().unwrap();
376 b.join().unwrap().unwrap();
377 });
378
379 let roots = load_roots_in(&cache_path);
380 assert_eq!(roots.len(), 2, "both concurrent registrations must survive");
381 assert!(roots.iter().any(|r| r.project_root == root_a));
382 assert!(roots.iter().any(|r| r.project_root == root_b));
383 }
384
385 #[test]
389 fn concurrent_registration_of_same_pair_results_in_one_valid_entry() {
390 let cache = tempfile::tempdir().unwrap();
391 let cache_path = cache.path().to_path_buf();
392 let root = PathBuf::from("/tmp/concurrent-project-same");
393
394 std::thread::scope(|scope| {
395 let a = scope.spawn(|| register_in(&cache_path, &root, 1));
396 let b = scope.spawn(|| register_in(&cache_path, &root, 1));
397 a.join().unwrap().unwrap();
398 b.join().unwrap().unwrap();
399 });
400
401 let entry_path = entry_path_in(&cache_path, &root, 1);
402 let contents = std::fs::read_to_string(&entry_path).unwrap();
403 let parsed: RegisteredRoot =
404 serde_json::from_str(&contents).expect("entry must not be torn");
405 assert_eq!(parsed.project_root, root);
406
407 let roots = load_roots_in(&cache_path);
408 assert_eq!(roots.len(), 1);
409 }
410
411 #[test]
415 fn register_in_creates_cache_and_roots_dirs_with_mode_0700() {
416 use std::os::unix::fs::PermissionsExt;
417 let base = tempfile::tempdir().unwrap();
418 let cache_path = base.path().join("nested-cache");
419 let root = PathBuf::from("/tmp/project-perm");
420
421 register_in(&cache_path, &root, 1).unwrap();
422
423 let cache_mode = std::fs::metadata(&cache_path).unwrap().permissions().mode() & 0o777;
424 assert_eq!(
425 cache_mode, 0o700,
426 "cache dir must be created with mode 0700"
427 );
428
429 let roots_mode = std::fs::metadata(roots_dir_in(&cache_path))
430 .unwrap()
431 .permissions()
432 .mode()
433 & 0o777;
434 assert_eq!(
435 roots_mode, 0o700,
436 "roots dir must be created with mode 0700"
437 );
438 }
439
440 #[test]
441 fn prune_missing_in_removes_entry_for_deleted_root_and_reports_count() {
442 let cache = tempfile::tempdir().unwrap();
443 let project = tempfile::tempdir().unwrap();
444 let project_path = project.path().to_path_buf();
445 register_in(cache.path(), &project_path, 1).unwrap();
446 drop(project); let removed = prune_missing_in(cache.path());
449
450 assert_eq!(removed, 1);
451 assert!(load_roots_in(cache.path()).is_empty());
452 }
453
454 #[test]
455 fn prune_missing_in_keeps_entry_for_existing_root() {
456 let cache = tempfile::tempdir().unwrap();
457 let project = tempfile::tempdir().unwrap();
458 register_in(cache.path(), project.path(), 1).unwrap();
459
460 let removed = prune_missing_in(cache.path());
461
462 assert_eq!(removed, 0);
463 assert_eq!(load_roots_in(cache.path()).len(), 1);
464 }
465
466 #[test]
467 fn prune_missing_in_removes_and_counts_unparsable_entry() {
468 let cache = tempfile::tempdir().unwrap();
469 let dir = roots_dir_in(cache.path());
470 std::fs::create_dir_all(&dir).unwrap();
471 std::fs::write(dir.join("junk.json"), "{not json").unwrap();
472
473 let removed = prune_missing_in(cache.path());
474
475 assert_eq!(removed, 1);
476 assert!(load_roots_in(cache.path()).is_empty());
477 }
478
479 #[test]
480 fn dereg_removes_matching_pair_and_leaves_sibling_phase_intact() {
481 let cache = tempfile::tempdir().unwrap();
482 let root = PathBuf::from("/tmp/project-dereg-phase");
483 register_in(cache.path(), &root, 1).unwrap();
484 register_in(cache.path(), &root, 2).unwrap();
485
486 deregister_in(cache.path(), &root, 1).unwrap();
487
488 let roots = load_roots_in(cache.path());
489 assert_eq!(roots.len(), 1);
490 assert_eq!(roots[0].phase, 2);
491 }
492
493 #[test]
496 fn dereg_is_scoped_to_one_root_and_leaves_sibling_root_intact() {
497 let cache = tempfile::tempdir().unwrap();
498 let root_a = PathBuf::from("/tmp/project-dereg-root-a");
499 let root_b = PathBuf::from("/tmp/project-dereg-root-b");
500 register_in(cache.path(), &root_a, 1).unwrap();
501 register_in(cache.path(), &root_b, 1).unwrap();
502
503 deregister_in(cache.path(), &root_a, 1).unwrap();
504
505 let roots = load_roots_in(cache.path());
506 assert_eq!(roots.len(), 1);
507 assert_eq!(roots[0].project_root, root_b);
508 }
509
510 #[test]
511 fn dereg_on_never_registered_pair_is_a_noop() {
512 let cache = tempfile::tempdir().unwrap();
513 let root = PathBuf::from("/tmp/project-never-registered");
514
515 deregister_in(cache.path(), &root, 1).unwrap();
516
517 assert!(load_roots_in(cache.path()).is_empty());
518 }
519
520 #[test]
523 fn dereg_is_idempotent_when_entry_already_removed() {
524 let cache = tempfile::tempdir().unwrap();
525 let root = PathBuf::from("/tmp/project-dereg-idempotent");
526 register_in(cache.path(), &root, 1).unwrap();
527
528 deregister_in(cache.path(), &root, 1).unwrap();
529 deregister_in(cache.path(), &root, 1).unwrap();
530
531 assert!(load_roots_in(cache.path()).is_empty());
532 }
533
534 #[test]
535 fn path_digest_is_stable_and_distinguishes_different_paths() {
536 let a = Path::new("/tmp/project-a");
537 let b = Path::new("/tmp/project-b");
538
539 assert_eq!(path_digest(a), path_digest(a), "digest must be stable");
540 assert_ne!(
541 path_digest(a),
542 path_digest(b),
543 "different paths must yield different digests"
544 );
545
546 let cache = Path::new("/tmp/cache");
547 assert_ne!(
548 entry_path_in(cache, a, 1),
549 entry_path_in(cache, b, 1),
550 "different project roots must yield different entry paths"
551 );
552 }
553}