use crate::phase_id::PhaseId;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisteredRoot {
pub project_root: PathBuf,
pub phase: PhaseId,
pub registered_at: String,
}
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error("registry I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("registry JSON failed: {0}")]
Json(#[from] serde_json::Error),
}
pub fn cache_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("DEVFLOW_CACHE_DIR") {
return Some(PathBuf::from(dir));
}
if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") {
return Some(PathBuf::from(dir).join("devflow"));
}
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".cache").join("devflow"))
}
pub fn roots_dir_in(cache_dir: &Path) -> PathBuf {
cache_dir.join("roots")
}
pub fn entry_path_in(cache_dir: &Path, project_root: &Path, phase: PhaseId) -> PathBuf {
let digest = path_digest(project_root);
roots_dir_in(cache_dir).join(format!(
"{digest:016x}-{padded}.json",
padded = phase.padded()
))
}
fn path_digest(path: &Path) -> u64 {
use std::os::unix::ffi::OsStrExt;
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET_BASIS;
for byte in path.as_os_str().as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
pub fn register_in(
cache_dir: &Path,
project_root: &Path,
phase: PhaseId,
) -> Result<(), RegistryError> {
ensure_private_dir(cache_dir)?;
let dir = roots_dir_in(cache_dir);
ensure_private_dir(&dir)?;
let entry = RegisteredRoot {
project_root: project_root.to_path_buf(),
phase,
registered_at: unix_now(),
};
let path = entry_path_in(cache_dir, project_root, phase);
write_atomic(&path, &serde_json::to_string_pretty(&entry)?)?;
Ok(())
}
pub fn prune_missing_in(cache_dir: &Path) -> usize {
let mut removed = 0;
let dir = roots_dir_in(cache_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return 0;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.ends_with(".json") {
continue;
}
let path = entry.path();
let root_still_exists = std::fs::read_to_string(&path)
.ok()
.and_then(|contents| serde_json::from_str::<RegisteredRoot>(&contents).ok())
.is_some_and(|root| root.project_root.is_dir());
if !root_still_exists && std::fs::remove_file(&path).is_ok() {
removed += 1;
}
}
removed
}
pub fn prune_missing() -> usize {
let Some(dir) = cache_dir() else {
return 0;
};
prune_missing_in(&dir)
}
pub fn deregister_in(
cache_dir: &Path,
project_root: &Path,
phase: PhaseId,
) -> Result<(), RegistryError> {
let path = entry_path_in(cache_dir, project_root, phase);
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
pub fn deregister(project_root: &Path, phase: PhaseId) {
let Some(dir) = cache_dir() else {
return;
};
let _ = deregister_in(&dir, project_root, phase);
}
fn ensure_private_dir(dir: &Path) -> Result<(), RegistryError> {
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir_all(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
Ok(())
}
fn write_atomic(path: &Path, contents: &str) -> Result<(), RegistryError> {
static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
std::fs::write(&tmp, contents)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn load_roots_in(cache_dir: &Path) -> Vec<RegisteredRoot> {
let mut roots = Vec::new();
let dir = roots_dir_in(cache_dir);
let Ok(entries) = std::fs::read_dir(&dir) else {
return roots;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.ends_with(".json") {
continue;
}
let Ok(contents) = std::fs::read_to_string(entry.path()) else {
continue;
};
let Ok(root) = serde_json::from_str::<RegisteredRoot>(&contents) else {
continue;
};
roots.push(root);
}
roots.sort_by(|a, b| (&a.project_root, a.phase).cmp(&(&b.project_root, b.phase)));
roots
}
pub fn register(project_root: &Path, phase: PhaseId) -> Result<(), RegistryError> {
let Some(dir) = cache_dir() else {
return Ok(());
};
register_in(&dir, project_root, phase)
}
pub fn load_roots() -> Vec<RegisteredRoot> {
let Some(dir) = cache_dir() else {
return Vec::new();
};
load_roots_in(&dir)
}
fn unix_now() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_in_two_different_pairs_both_survive_and_load_sorted() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path();
let root_a = PathBuf::from("/tmp/project-a");
let root_b = PathBuf::from("/tmp/project-b");
register_in(cache, &root_a, PhaseId::new(5)).unwrap();
register_in(cache, &root_b, PhaseId::new(7)).unwrap();
let roots = load_roots_in(cache);
assert_eq!(roots.len(), 2);
assert!(
roots
.iter()
.any(|r| r.project_root == root_a && r.phase == PhaseId::new(5))
);
assert!(
roots
.iter()
.any(|r| r.project_root == root_b && r.phase == PhaseId::new(7))
);
assert!(roots[0].project_root <= roots[1].project_root);
}
#[test]
fn register_in_same_root_two_phases_survive_as_distinct_files() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path();
let root = PathBuf::from("/tmp/project-multi-phase");
register_in(cache, &root, PhaseId::new(1)).unwrap();
register_in(cache, &root, PhaseId::new(2)).unwrap();
let roots = load_roots_in(cache);
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.phase == PhaseId::new(1)));
assert!(roots.iter().any(|r| r.phase == PhaseId::new(2)));
}
#[test]
fn load_roots_in_skips_one_corrupt_entry_and_keeps_its_sibling() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path();
let root = PathBuf::from("/tmp/project-good");
register_in(cache, &root, PhaseId::new(3)).unwrap();
let junk_path = roots_dir_in(cache).join("junk-entry.json");
std::fs::write(&junk_path, "{not json").unwrap();
let roots = load_roots_in(cache);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].project_root, root);
assert_eq!(roots[0].phase, PhaseId::new(3));
}
#[test]
fn load_roots_in_on_absent_directory_returns_empty_without_panicking() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("never-created");
assert!(load_roots_in(&cache).is_empty());
}
#[test]
fn register_in_same_pair_twice_results_in_exactly_one_entry() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path();
let root = PathBuf::from("/tmp/project-reregister");
register_in(cache, &root, PhaseId::new(9)).unwrap();
register_in(cache, &root, PhaseId::new(9)).unwrap();
let roots = load_roots_in(cache);
assert_eq!(roots.len(), 1);
}
#[test]
fn concurrent_registration_of_different_pairs_both_survive() {
let cache = tempfile::tempdir().unwrap();
let cache_path = cache.path().to_path_buf();
let root_a = PathBuf::from("/tmp/concurrent-project-a");
let root_b = PathBuf::from("/tmp/concurrent-project-b");
std::thread::scope(|scope| {
let a = scope.spawn(|| register_in(&cache_path, &root_a, PhaseId::new(1)));
let b = scope.spawn(|| register_in(&cache_path, &root_b, PhaseId::new(1)));
a.join().unwrap().unwrap();
b.join().unwrap().unwrap();
});
let roots = load_roots_in(&cache_path);
assert_eq!(roots.len(), 2, "both concurrent registrations must survive");
assert!(roots.iter().any(|r| r.project_root == root_a));
assert!(roots.iter().any(|r| r.project_root == root_b));
}
#[test]
fn concurrent_registration_of_same_pair_results_in_one_valid_entry() {
let cache = tempfile::tempdir().unwrap();
let cache_path = cache.path().to_path_buf();
let root = PathBuf::from("/tmp/concurrent-project-same");
std::thread::scope(|scope| {
let a = scope.spawn(|| register_in(&cache_path, &root, PhaseId::new(1)));
let b = scope.spawn(|| register_in(&cache_path, &root, PhaseId::new(1)));
a.join().unwrap().unwrap();
b.join().unwrap().unwrap();
});
let entry_path = entry_path_in(&cache_path, &root, PhaseId::new(1));
let contents = std::fs::read_to_string(&entry_path).unwrap();
let parsed: RegisteredRoot =
serde_json::from_str(&contents).expect("entry must not be torn");
assert_eq!(parsed.project_root, root);
let roots = load_roots_in(&cache_path);
assert_eq!(roots.len(), 1);
}
#[test]
fn register_in_creates_cache_and_roots_dirs_with_mode_0700() {
use std::os::unix::fs::PermissionsExt;
let base = tempfile::tempdir().unwrap();
let cache_path = base.path().join("nested-cache");
let root = PathBuf::from("/tmp/project-perm");
register_in(&cache_path, &root, PhaseId::new(1)).unwrap();
let cache_mode = std::fs::metadata(&cache_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
cache_mode, 0o700,
"cache dir must be created with mode 0700"
);
let roots_mode = std::fs::metadata(roots_dir_in(&cache_path))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
roots_mode, 0o700,
"roots dir must be created with mode 0700"
);
}
#[test]
fn prune_missing_in_removes_entry_for_deleted_root_and_reports_count() {
let cache = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let project_path = project.path().to_path_buf();
register_in(cache.path(), &project_path, PhaseId::new(1)).unwrap();
drop(project);
let removed = prune_missing_in(cache.path());
assert_eq!(removed, 1);
assert!(load_roots_in(cache.path()).is_empty());
}
#[test]
fn prune_missing_in_keeps_entry_for_existing_root() {
let cache = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
register_in(cache.path(), project.path(), PhaseId::new(1)).unwrap();
let removed = prune_missing_in(cache.path());
assert_eq!(removed, 0);
assert_eq!(load_roots_in(cache.path()).len(), 1);
}
#[test]
fn prune_missing_in_removes_and_counts_unparsable_entry() {
let cache = tempfile::tempdir().unwrap();
let dir = roots_dir_in(cache.path());
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("junk.json"), "{not json").unwrap();
let removed = prune_missing_in(cache.path());
assert_eq!(removed, 1);
assert!(load_roots_in(cache.path()).is_empty());
}
#[test]
fn dereg_removes_matching_pair_and_leaves_sibling_phase_intact() {
let cache = tempfile::tempdir().unwrap();
let root = PathBuf::from("/tmp/project-dereg-phase");
register_in(cache.path(), &root, PhaseId::new(1)).unwrap();
register_in(cache.path(), &root, PhaseId::new(2)).unwrap();
deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
let roots = load_roots_in(cache.path());
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].phase, PhaseId::new(2));
}
#[test]
fn dereg_is_scoped_to_one_root_and_leaves_sibling_root_intact() {
let cache = tempfile::tempdir().unwrap();
let root_a = PathBuf::from("/tmp/project-dereg-root-a");
let root_b = PathBuf::from("/tmp/project-dereg-root-b");
register_in(cache.path(), &root_a, PhaseId::new(1)).unwrap();
register_in(cache.path(), &root_b, PhaseId::new(1)).unwrap();
deregister_in(cache.path(), &root_a, PhaseId::new(1)).unwrap();
let roots = load_roots_in(cache.path());
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].project_root, root_b);
}
#[test]
fn dereg_on_never_registered_pair_is_a_noop() {
let cache = tempfile::tempdir().unwrap();
let root = PathBuf::from("/tmp/project-never-registered");
deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
assert!(load_roots_in(cache.path()).is_empty());
}
#[test]
fn dereg_is_idempotent_when_entry_already_removed() {
let cache = tempfile::tempdir().unwrap();
let root = PathBuf::from("/tmp/project-dereg-idempotent");
register_in(cache.path(), &root, PhaseId::new(1)).unwrap();
deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
deregister_in(cache.path(), &root, PhaseId::new(1)).unwrap();
assert!(load_roots_in(cache.path()).is_empty());
}
#[test]
fn path_digest_is_stable_and_distinguishes_different_paths() {
let a = Path::new("/tmp/project-a");
let b = Path::new("/tmp/project-b");
assert_eq!(path_digest(a), path_digest(a), "digest must be stable");
assert_ne!(
path_digest(a),
path_digest(b),
"different paths must yield different digests"
);
let cache = Path::new("/tmp/cache");
assert_ne!(
entry_path_in(cache, a, PhaseId::new(1)),
entry_path_in(cache, b, PhaseId::new(1)),
"different project roots must yield different entry paths"
);
}
}