1use crate::phase_id::PhaseId;
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RegisteredRoot {
26 pub project_root: PathBuf,
28 pub phase: PhaseId,
30 pub registered_at: String,
33}
34
35#[derive(Debug, thiserror::Error)]
37pub enum RegistryError {
38 #[error("registry I/O failed: {0}")]
40 Io(#[from] std::io::Error),
41 #[error("registry JSON failed: {0}")]
43 Json(#[from] serde_json::Error),
44}
45
46pub fn cache_dir() -> Option<PathBuf> {
54 if let Some(dir) = std::env::var_os("DEVFLOW_CACHE_DIR") {
55 return Some(PathBuf::from(dir));
56 }
57 if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
58 return Some(PathBuf::from(dir).join("devflow"));
59 }
60 let home = std::env::var_os("HOME")?;
61 Some(PathBuf::from(home).join(".cache").join("devflow"))
62}
63
64pub fn roots_dir_in(cache_dir: &Path) -> PathBuf {
67 cache_dir.join("roots")
68}
69
70pub fn entry_path_in(cache_dir: &Path, project_root: &Path, phase: PhaseId) -> PathBuf {
76 let digest = path_digest(project_root);
77 roots_dir_in(cache_dir).join(format!(
78 "{digest:016x}-{padded}.json",
79 padded = phase.padded()
80 ))
81}
82
83fn path_digest(path: &Path) -> u64 {
89 use std::os::unix::ffi::OsStrExt;
90 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
91 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
92 let mut hash = FNV_OFFSET_BASIS;
93 for byte in path.as_os_str().as_bytes() {
94 hash ^= u64::from(*byte);
95 hash = hash.wrapping_mul(FNV_PRIME);
96 }
97 hash
98}
99
100pub fn register_in(
115 cache_dir: &Path,
116 project_root: &Path,
117 phase: PhaseId,
118) -> Result<(), RegistryError> {
119 ensure_private_dir(cache_dir)?;
120 let dir = roots_dir_in(cache_dir);
121 ensure_private_dir(&dir)?;
122 let entry = RegisteredRoot {
123 project_root: project_root.to_path_buf(),
124 phase,
125 registered_at: unix_now(),
126 };
127 let path = entry_path_in(cache_dir, project_root, phase);
128 write_atomic(&path, &serde_json::to_string_pretty(&entry)?)?;
129 Ok(())
130}
131
132pub fn prune_missing_in(cache_dir: &Path) -> usize {
141 let mut removed = 0;
142 let dir = roots_dir_in(cache_dir);
143 let Ok(entries) = std::fs::read_dir(&dir) else {
144 return 0;
145 };
146 for entry in entries.flatten() {
147 let name = entry.file_name();
148 let Some(name) = name.to_str() else { continue };
149 if !name.ends_with(".json") {
150 continue;
151 }
152 let path = entry.path();
153 let root_still_exists = std::fs::read_to_string(&path)
154 .ok()
155 .and_then(|contents| serde_json::from_str::<RegisteredRoot>(&contents).ok())
156 .is_some_and(|root| root.project_root.is_dir());
157 if !root_still_exists && std::fs::remove_file(&path).is_ok() {
158 removed += 1;
159 }
160 }
161 removed
162}
163
164pub fn prune_missing() -> usize {
167 let Some(dir) = cache_dir() else {
168 return 0;
169 };
170 prune_missing_in(&dir)
171}
172
173pub fn deregister_in(
180 cache_dir: &Path,
181 project_root: &Path,
182 phase: PhaseId,
183) -> Result<(), RegistryError> {
184 let path = entry_path_in(cache_dir, project_root, phase);
185 match std::fs::remove_file(path) {
186 Ok(()) => Ok(()),
187 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
188 Err(err) => Err(err.into()),
189 }
190}
191
192pub fn deregister(project_root: &Path, phase: PhaseId) {
197 let Some(dir) = cache_dir() else {
198 return;
199 };
200 let _ = deregister_in(&dir, project_root, phase);
201}
202
203fn ensure_private_dir(dir: &Path) -> Result<(), RegistryError> {
208 use std::os::unix::fs::PermissionsExt;
209 std::fs::create_dir_all(dir)?;
210 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
211 Ok(())
212}
213
214fn write_atomic(path: &Path, contents: &str) -> Result<(), RegistryError> {
223 static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
224 let n = TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
225 let tmp = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
226 std::fs::write(&tmp, contents)?;
227 std::fs::rename(&tmp, path)?;
228 Ok(())
229}
230
231pub fn load_roots_in(cache_dir: &Path) -> Vec<RegisteredRoot> {
238 let mut roots = Vec::new();
239 let dir = roots_dir_in(cache_dir);
240 let Ok(entries) = std::fs::read_dir(&dir) else {
241 return roots;
242 };
243 for entry in entries.flatten() {
244 let name = entry.file_name();
245 let Some(name) = name.to_str() else { continue };
246 if !name.ends_with(".json") {
247 continue;
248 }
249 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
250 continue;
251 };
252 let Ok(root) = serde_json::from_str::<RegisteredRoot>(&contents) else {
253 continue;
254 };
255 roots.push(root);
256 }
257 roots.sort_by(|a, b| (&a.project_root, a.phase).cmp(&(&b.project_root, b.phase)));
258 roots
259}
260
261pub fn register(project_root: &Path, phase: PhaseId) -> Result<(), RegistryError> {
266 let Some(dir) = cache_dir() else {
267 return Ok(());
268 };
269 register_in(&dir, project_root, phase)
270}
271
272pub fn load_roots() -> Vec<RegisteredRoot> {
275 let Some(dir) = cache_dir() else {
276 return Vec::new();
277 };
278 load_roots_in(&dir)
279}
280
281fn unix_now() -> String {
282 std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .map(|d| d.as_secs().to_string())
285 .unwrap_or_else(|_| "0".to_string())
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn register_in_two_different_pairs_both_survive_and_load_sorted() {
294 let dir = tempfile::tempdir().unwrap();
295 let cache = dir.path();
296 let root_a = PathBuf::from("/tmp/project-a");
297 let root_b = PathBuf::from("/tmp/project-b");
298
299 register_in(cache, &root_a, PhaseId::new(5)).unwrap();
300 register_in(cache, &root_b, PhaseId::new(7)).unwrap();
301
302 let roots = load_roots_in(cache);
303 assert_eq!(roots.len(), 2);
304 assert!(
305 roots
306 .iter()
307 .any(|r| r.project_root == root_a && r.phase == PhaseId::new(5))
308 );
309 assert!(
310 roots
311 .iter()
312 .any(|r| r.project_root == root_b && r.phase == PhaseId::new(7))
313 );
314 assert!(roots[0].project_root <= roots[1].project_root);
316 }
317
318 #[test]
319 fn register_in_same_root_two_phases_survive_as_distinct_files() {
320 let dir = tempfile::tempdir().unwrap();
321 let cache = dir.path();
322 let root = PathBuf::from("/tmp/project-multi-phase");
323
324 register_in(cache, &root, PhaseId::new(1)).unwrap();
325 register_in(cache, &root, PhaseId::new(2)).unwrap();
326
327 let roots = load_roots_in(cache);
328 assert_eq!(roots.len(), 2);
329 assert!(roots.iter().any(|r| r.phase == PhaseId::new(1)));
330 assert!(roots.iter().any(|r| r.phase == PhaseId::new(2)));
331 }
332
333 #[test]
334 fn load_roots_in_skips_one_corrupt_entry_and_keeps_its_sibling() {
335 let dir = tempfile::tempdir().unwrap();
336 let cache = dir.path();
337 let root = PathBuf::from("/tmp/project-good");
338 register_in(cache, &root, PhaseId::new(3)).unwrap();
339
340 let junk_path = roots_dir_in(cache).join("junk-entry.json");
341 std::fs::write(&junk_path, "{not json").unwrap();
342
343 let roots = load_roots_in(cache);
344 assert_eq!(roots.len(), 1);
345 assert_eq!(roots[0].project_root, root);
346 assert_eq!(roots[0].phase, PhaseId::new(3));
347 }
348
349 #[test]
350 fn load_roots_in_on_absent_directory_returns_empty_without_panicking() {
351 let dir = tempfile::tempdir().unwrap();
352 let cache = dir.path().join("never-created");
353 assert!(load_roots_in(&cache).is_empty());
354 }
355
356 #[test]
357 fn register_in_same_pair_twice_results_in_exactly_one_entry() {
358 let dir = tempfile::tempdir().unwrap();
359 let cache = dir.path();
360 let root = PathBuf::from("/tmp/project-reregister");
361
362 register_in(cache, &root, PhaseId::new(9)).unwrap();
363 register_in(cache, &root, PhaseId::new(9)).unwrap();
364
365 let roots = load_roots_in(cache);
366 assert_eq!(roots.len(), 1);
367 }
368
369 #[test]
374 fn concurrent_registration_of_different_pairs_both_survive() {
375 let cache = tempfile::tempdir().unwrap();
376 let cache_path = cache.path().to_path_buf();
377 let root_a = PathBuf::from("/tmp/concurrent-project-a");
378 let root_b = PathBuf::from("/tmp/concurrent-project-b");
379
380 std::thread::scope(|scope| {
381 let a = scope.spawn(|| register_in(&cache_path, &root_a, PhaseId::new(1)));
382 let b = scope.spawn(|| register_in(&cache_path, &root_b, PhaseId::new(1)));
383 a.join().unwrap().unwrap();
384 b.join().unwrap().unwrap();
385 });
386
387 let roots = load_roots_in(&cache_path);
388 assert_eq!(roots.len(), 2, "both concurrent registrations must survive");
389 assert!(roots.iter().any(|r| r.project_root == root_a));
390 assert!(roots.iter().any(|r| r.project_root == root_b));
391 }
392
393 #[test]
397 fn concurrent_registration_of_same_pair_results_in_one_valid_entry() {
398 let cache = tempfile::tempdir().unwrap();
399 let cache_path = cache.path().to_path_buf();
400 let root = PathBuf::from("/tmp/concurrent-project-same");
401
402 std::thread::scope(|scope| {
403 let a = scope.spawn(|| register_in(&cache_path, &root, PhaseId::new(1)));
404 let b = scope.spawn(|| register_in(&cache_path, &root, PhaseId::new(1)));
405 a.join().unwrap().unwrap();
406 b.join().unwrap().unwrap();
407 });
408
409 let entry_path = entry_path_in(&cache_path, &root, PhaseId::new(1));
410 let contents = std::fs::read_to_string(&entry_path).unwrap();
411 let parsed: RegisteredRoot =
412 serde_json::from_str(&contents).expect("entry must not be torn");
413 assert_eq!(parsed.project_root, root);
414
415 let roots = load_roots_in(&cache_path);
416 assert_eq!(roots.len(), 1);
417 }
418
419 #[test]
423 fn register_in_creates_cache_and_roots_dirs_with_mode_0700() {
424 use std::os::unix::fs::PermissionsExt;
425 let base = tempfile::tempdir().unwrap();
426 let cache_path = base.path().join("nested-cache");
427 let root = PathBuf::from("/tmp/project-perm");
428
429 register_in(&cache_path, &root, PhaseId::new(1)).unwrap();
430
431 let cache_mode = std::fs::metadata(&cache_path).unwrap().permissions().mode() & 0o777;
432 assert_eq!(
433 cache_mode, 0o700,
434 "cache dir must be created with mode 0700"
435 );
436
437 let roots_mode = std::fs::metadata(roots_dir_in(&cache_path))
438 .unwrap()
439 .permissions()
440 .mode()
441 & 0o777;
442 assert_eq!(
443 roots_mode, 0o700,
444 "roots dir must be created with mode 0700"
445 );
446 }
447
448 #[test]
449 fn prune_missing_in_removes_entry_for_deleted_root_and_reports_count() {
450 let cache = tempfile::tempdir().unwrap();
451 let project = tempfile::tempdir().unwrap();
452 let project_path = project.path().to_path_buf();
453 register_in(cache.path(), &project_path, PhaseId::new(1)).unwrap();
454 drop(project); let removed = prune_missing_in(cache.path());
457
458 assert_eq!(removed, 1);
459 assert!(load_roots_in(cache.path()).is_empty());
460 }
461
462 #[test]
463 fn prune_missing_in_keeps_entry_for_existing_root() {
464 let cache = tempfile::tempdir().unwrap();
465 let project = tempfile::tempdir().unwrap();
466 register_in(cache.path(), project.path(), PhaseId::new(1)).unwrap();
467
468 let removed = prune_missing_in(cache.path());
469
470 assert_eq!(removed, 0);
471 assert_eq!(load_roots_in(cache.path()).len(), 1);
472 }
473
474 #[test]
475 fn prune_missing_in_removes_and_counts_unparsable_entry() {
476 let cache = tempfile::tempdir().unwrap();
477 let dir = roots_dir_in(cache.path());
478 std::fs::create_dir_all(&dir).unwrap();
479 std::fs::write(dir.join("junk.json"), "{not json").unwrap();
480
481 let removed = prune_missing_in(cache.path());
482
483 assert_eq!(removed, 1);
484 assert!(load_roots_in(cache.path()).is_empty());
485 }
486
487 #[test]
488 fn dereg_removes_matching_pair_and_leaves_sibling_phase_intact() {
489 let cache = tempfile::tempdir().unwrap();
490 let root = PathBuf::from("/tmp/project-dereg-phase");
491 register_in(cache.path(), &root, PhaseId::new(1)).unwrap();
492 register_in(cache.path(), &root, PhaseId::new(2)).unwrap();
493
494 deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
495
496 let roots = load_roots_in(cache.path());
497 assert_eq!(roots.len(), 1);
498 assert_eq!(roots[0].phase, PhaseId::new(2));
499 }
500
501 #[test]
504 fn dereg_is_scoped_to_one_root_and_leaves_sibling_root_intact() {
505 let cache = tempfile::tempdir().unwrap();
506 let root_a = PathBuf::from("/tmp/project-dereg-root-a");
507 let root_b = PathBuf::from("/tmp/project-dereg-root-b");
508 register_in(cache.path(), &root_a, PhaseId::new(1)).unwrap();
509 register_in(cache.path(), &root_b, PhaseId::new(1)).unwrap();
510
511 deregister_in(cache.path(), &root_a, PhaseId::new(1)).unwrap();
512
513 let roots = load_roots_in(cache.path());
514 assert_eq!(roots.len(), 1);
515 assert_eq!(roots[0].project_root, root_b);
516 }
517
518 #[test]
519 fn dereg_on_never_registered_pair_is_a_noop() {
520 let cache = tempfile::tempdir().unwrap();
521 let root = PathBuf::from("/tmp/project-never-registered");
522
523 deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
524
525 assert!(load_roots_in(cache.path()).is_empty());
526 }
527
528 #[test]
531 fn dereg_is_idempotent_when_entry_already_removed() {
532 let cache = tempfile::tempdir().unwrap();
533 let root = PathBuf::from("/tmp/project-dereg-idempotent");
534 register_in(cache.path(), &root, PhaseId::new(1)).unwrap();
535
536 deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
537 deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
538
539 assert!(load_roots_in(cache.path()).is_empty());
540 }
541
542 #[test]
543 fn path_digest_is_stable_and_distinguishes_different_paths() {
544 let a = Path::new("/tmp/project-a");
545 let b = Path::new("/tmp/project-b");
546
547 assert_eq!(path_digest(a), path_digest(a), "digest must be stable");
548 assert_ne!(
549 path_digest(a),
550 path_digest(b),
551 "different paths must yield different digests"
552 );
553
554 let cache = Path::new("/tmp/cache");
555 assert_ne!(
556 entry_path_in(cache, a, PhaseId::new(1)),
557 entry_path_in(cache, b, PhaseId::new(1)),
558 "different project roots must yield different entry paths"
559 );
560 }
561}