use crate::{lock_file, project_id, store_root, write_atomic};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub project_id: String,
pub path: String,
pub name: String,
pub registered_at: String,
pub last_seen: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Registry {
pub projects: BTreeMap<String, ProjectEntry>,
}
pub fn registry_path() -> PathBuf {
store_root().join("registry.json")
}
fn registry_lock_path() -> PathBuf {
store_root().join("registry.lock")
}
fn load_registry() -> Registry {
let path = registry_path();
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Registry::default(),
}
}
fn save_registry(reg: &Registry) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(reg)?;
write_atomic(®istry_path(), json.as_bytes())
}
fn now_rfc3339() -> String {
let now = OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".to_string())
}
fn project_name_from_path(repo_root: &Path) -> String {
repo_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub fn register_project(repo_root: &Path) -> anyhow::Result<()> {
let _lock = lock_file(®istry_lock_path())?;
let pid = project_id(repo_root);
let mut reg = load_registry();
let now = now_rfc3339();
if let Some(entry) = reg.projects.get_mut(&pid) {
entry.last_seen = now;
entry.path = repo_root.to_string_lossy().to_string();
} else {
reg.projects.insert(
pid.clone(),
ProjectEntry {
project_id: pid,
path: repo_root.to_string_lossy().to_string(),
name: project_name_from_path(repo_root),
registered_at: now.clone(),
last_seen: now,
group: None,
},
);
}
save_registry(®)
}
pub fn unregister_project(pid: &str) -> anyhow::Result<()> {
let _lock = lock_file(®istry_lock_path())?;
let mut reg = load_registry();
reg.projects.remove(pid);
save_registry(®)
}
pub fn list_projects() -> Vec<ProjectEntry> {
let reg = load_registry();
reg.projects.into_values().collect()
}
pub fn get_project(pid: &str) -> Option<ProjectEntry> {
let reg = load_registry();
reg.projects.get(pid).cloned()
}
pub fn touch_project(repo_root: &Path) -> anyhow::Result<()> {
let _lock = lock_file(®istry_lock_path())?;
let pid = project_id(repo_root);
let mut reg = load_registry();
if let Some(entry) = reg.projects.get_mut(&pid) {
entry.last_seen = now_rfc3339();
save_registry(®)?;
}
Ok(())
}
pub fn set_project_group(repo_root: &Path, group: Option<&str>) -> anyhow::Result<()> {
let _lock = lock_file(®istry_lock_path())?;
let pid = project_id(repo_root);
let mut reg = load_registry();
if let Some(entry) = reg.projects.get_mut(&pid) {
entry.group = group.map(|g| g.to_string());
save_registry(®)
} else {
anyhow::bail!("project not registered: {pid}")
}
}
pub fn project_group(repo_root: &Path) -> Option<String> {
let pid = project_id(repo_root);
let reg = load_registry();
reg.projects.get(&pid).and_then(|e| e.group.clone())
}
pub fn list_group_members(repo_root: &Path) -> Vec<ProjectEntry> {
let pid = project_id(repo_root);
let reg = load_registry();
let group = match reg.projects.get(&pid).and_then(|e| e.group.as_ref()) {
Some(g) => g.clone(),
None => return Vec::new(),
};
reg.projects
.into_values()
.filter(|e| e.group.as_deref() == Some(&group) && e.project_id != pid)
.collect()
}
pub fn list_groups() -> std::collections::BTreeMap<String, Vec<ProjectEntry>> {
let reg = load_registry();
let mut groups: std::collections::BTreeMap<String, Vec<ProjectEntry>> =
std::collections::BTreeMap::new();
for entry in reg.projects.into_values() {
if let Some(ref g) = entry.group {
groups.entry(g.clone()).or_default().push(entry);
}
}
groups
}
pub fn validate_projects() -> (Vec<ProjectEntry>, Vec<ProjectEntry>) {
let reg = load_registry();
let mut valid = Vec::new();
let mut stale = Vec::new();
for entry in reg.projects.into_values() {
let edda_dir = Path::new(&entry.path).join(".edda");
if edda_dir.is_dir() {
valid.push(entry);
} else {
stale.push(entry);
}
}
(valid, stale)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_isolated_store(f: impl FnOnce()) {
let _guard = crate::ENV_STORE_LOCK.lock().unwrap();
let store = tempfile::tempdir().unwrap();
std::env::set_var("EDDA_STORE_ROOT", store.path());
f();
std::env::remove_var("EDDA_STORE_ROOT");
}
#[test]
fn register_and_list_roundtrip() {
with_isolated_store(|| {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
register_project(tmp.path()).unwrap();
let projects = list_projects();
let pid = project_id(tmp.path());
assert!(projects.iter().any(|p| p.project_id == pid));
});
}
#[test]
fn register_is_idempotent() {
with_isolated_store(|| {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
register_project(tmp.path()).unwrap();
register_project(tmp.path()).unwrap();
let pid = project_id(tmp.path());
let reg = load_registry();
let count = reg
.projects
.values()
.filter(|p| p.project_id == pid)
.count();
assert_eq!(count, 1, "should not create duplicates");
});
}
#[test]
fn unregister_removes_entry() {
with_isolated_store(|| {
let tmp = tempfile::tempdir().unwrap();
let pid = project_id(tmp.path());
register_project(tmp.path()).unwrap();
assert!(get_project(&pid).is_some());
unregister_project(&pid).unwrap();
assert!(get_project(&pid).is_none());
});
}
#[test]
fn set_and_get_group() {
with_isolated_store(|| {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
register_project(tmp.path()).unwrap();
assert!(project_group(tmp.path()).is_none());
set_project_group(tmp.path(), Some("team-a")).unwrap();
assert_eq!(project_group(tmp.path()), Some("team-a".to_string()));
set_project_group(tmp.path(), None).unwrap();
assert!(project_group(tmp.path()).is_none());
});
}
#[test]
fn list_group_members_returns_peers() {
with_isolated_store(|| {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp1.path().join(".edda")).unwrap();
std::fs::create_dir_all(tmp2.path().join(".edda")).unwrap();
register_project(tmp1.path()).unwrap();
register_project(tmp2.path()).unwrap();
set_project_group(tmp1.path(), Some("team-x")).unwrap();
set_project_group(tmp2.path(), Some("team-x")).unwrap();
let members = list_group_members(tmp1.path());
assert_eq!(members.len(), 1);
assert_eq!(members[0].project_id, project_id(tmp2.path()));
let tmp3 = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp3.path().join(".edda")).unwrap();
register_project(tmp3.path()).unwrap();
assert!(list_group_members(tmp3.path()).is_empty());
});
}
#[test]
fn list_groups_returns_all() {
with_isolated_store(|| {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp1.path().join(".edda")).unwrap();
std::fs::create_dir_all(tmp2.path().join(".edda")).unwrap();
register_project(tmp1.path()).unwrap();
register_project(tmp2.path()).unwrap();
set_project_group(tmp1.path(), Some("alpha")).unwrap();
set_project_group(tmp2.path(), Some("alpha")).unwrap();
let groups = list_groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups["alpha"].len(), 2);
});
}
#[test]
fn group_backward_compat() {
with_isolated_store(|| {
let json = r#"{"projects":{"abc":{"project_id":"abc","path":"/tmp/x","name":"x","registered_at":"2026-01-01","last_seen":"2026-01-01"}}}"#;
let reg: Registry = serde_json::from_str(json).unwrap();
let entry = reg.projects.get("abc").unwrap();
assert!(entry.group.is_none());
});
}
#[test]
fn validate_detects_stale() {
with_isolated_store(|| {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
register_project(tmp.path()).unwrap();
let pid = project_id(tmp.path());
std::fs::remove_dir_all(tmp.path().join(".edda")).unwrap();
let (valid, stale) = validate_projects();
assert!(stale.iter().any(|p| p.project_id == pid));
assert!(!valid.iter().any(|p| p.project_id == pid));
});
}
}