Skip to main content

edda_store/
registry.rs

1//! Project registry: `~/.edda/registry.json`
2//!
3//! Maps project_id (BLAKE3 hash) to repo path. Used for cross-repo aggregation.
4
5use crate::{lock_file, project_id, store_root, write_atomic};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use time::OffsetDateTime;
10
11/// A registered project entry.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ProjectEntry {
14    pub project_id: String,
15    pub path: String,
16    pub name: String,
17    pub registered_at: String,
18    pub last_seen: String,
19    /// Optional group name for cross-project sync. Projects in the same group
20    /// share decisions marked with `scope=shared`.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub group: Option<String>,
23}
24
25/// The full registry: a map of project_id → ProjectEntry.
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct Registry {
28    pub projects: BTreeMap<String, ProjectEntry>,
29}
30
31/// Path to the registry JSON file.
32pub fn registry_path() -> PathBuf {
33    store_root().join("registry.json")
34}
35
36/// Path to the registry lock file.
37fn registry_lock_path() -> PathBuf {
38    store_root().join("registry.lock")
39}
40
41/// Load the registry from disk. Returns empty registry if file doesn't exist.
42fn load_registry() -> Registry {
43    let path = registry_path();
44    match std::fs::read_to_string(&path) {
45        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
46        Err(_) => Registry::default(),
47    }
48}
49
50/// Save the registry to disk atomically.
51fn save_registry(reg: &Registry) -> anyhow::Result<()> {
52    let json = serde_json::to_string_pretty(reg)?;
53    write_atomic(&registry_path(), json.as_bytes())
54}
55
56/// Get current timestamp as RFC 3339 string.
57fn now_rfc3339() -> String {
58    let now = OffsetDateTime::now_utc();
59    now.format(&time::format_description::well_known::Rfc3339)
60        .unwrap_or_else(|_| "unknown".to_string())
61}
62
63/// Extract project name from the repo path (last component).
64fn project_name_from_path(repo_root: &Path) -> String {
65    repo_root
66        .file_name()
67        .map(|n| n.to_string_lossy().to_string())
68        .unwrap_or_else(|| "unknown".to_string())
69}
70
71/// Register a project in the user-level registry. Idempotent.
72pub fn register_project(repo_root: &Path) -> anyhow::Result<()> {
73    let _lock = lock_file(&registry_lock_path())?;
74    let pid = project_id(repo_root);
75    let mut reg = load_registry();
76    let now = now_rfc3339();
77
78    if let Some(entry) = reg.projects.get_mut(&pid) {
79        // Update last_seen and path (in case repo was moved)
80        entry.last_seen = now;
81        entry.path = repo_root.to_string_lossy().to_string();
82    } else {
83        reg.projects.insert(
84            pid.clone(),
85            ProjectEntry {
86                project_id: pid,
87                path: repo_root.to_string_lossy().to_string(),
88                name: project_name_from_path(repo_root),
89                registered_at: now.clone(),
90                last_seen: now,
91                group: None,
92            },
93        );
94    }
95
96    save_registry(&reg)
97}
98
99/// Unregister a project by project_id.
100pub fn unregister_project(pid: &str) -> anyhow::Result<()> {
101    let _lock = lock_file(&registry_lock_path())?;
102    let mut reg = load_registry();
103    reg.projects.remove(pid);
104    save_registry(&reg)
105}
106
107/// List all registered projects.
108pub fn list_projects() -> Vec<ProjectEntry> {
109    let reg = load_registry();
110    reg.projects.into_values().collect()
111}
112
113/// Get a specific project by project_id.
114pub fn get_project(pid: &str) -> Option<ProjectEntry> {
115    let reg = load_registry();
116    reg.projects.get(pid).cloned()
117}
118
119/// Update last_seen timestamp for a project.
120pub fn touch_project(repo_root: &Path) -> anyhow::Result<()> {
121    let _lock = lock_file(&registry_lock_path())?;
122    let pid = project_id(repo_root);
123    let mut reg = load_registry();
124
125    if let Some(entry) = reg.projects.get_mut(&pid) {
126        entry.last_seen = now_rfc3339();
127        save_registry(&reg)?;
128    }
129
130    Ok(())
131}
132
133/// Set the group for a project. Pass `None` to remove the group.
134pub fn set_project_group(repo_root: &Path, group: Option<&str>) -> anyhow::Result<()> {
135    let _lock = lock_file(&registry_lock_path())?;
136    let pid = project_id(repo_root);
137    let mut reg = load_registry();
138
139    if let Some(entry) = reg.projects.get_mut(&pid) {
140        entry.group = group.map(|g| g.to_string());
141        save_registry(&reg)
142    } else {
143        anyhow::bail!("project not registered: {pid}")
144    }
145}
146
147/// Get the group for a project.
148pub fn project_group(repo_root: &Path) -> Option<String> {
149    let pid = project_id(repo_root);
150    let reg = load_registry();
151    reg.projects.get(&pid).and_then(|e| e.group.clone())
152}
153
154/// List all projects in the same group as the given project.
155/// Returns an empty vec if the project has no group.
156pub fn list_group_members(repo_root: &Path) -> Vec<ProjectEntry> {
157    let pid = project_id(repo_root);
158    let reg = load_registry();
159    let group = match reg.projects.get(&pid).and_then(|e| e.group.as_ref()) {
160        Some(g) => g.clone(),
161        None => return Vec::new(),
162    };
163    reg.projects
164        .into_values()
165        .filter(|e| e.group.as_deref() == Some(&group) && e.project_id != pid)
166        .collect()
167}
168
169/// List all groups and their member projects.
170pub fn list_groups() -> std::collections::BTreeMap<String, Vec<ProjectEntry>> {
171    let reg = load_registry();
172    let mut groups: std::collections::BTreeMap<String, Vec<ProjectEntry>> =
173        std::collections::BTreeMap::new();
174    for entry in reg.projects.into_values() {
175        if let Some(ref g) = entry.group {
176            groups.entry(g.clone()).or_default().push(entry);
177        }
178    }
179    groups
180}
181
182/// Validate all registered projects. Returns (valid, stale) entries.
183/// A project is stale if its path no longer contains a `.edda/` directory.
184pub fn validate_projects() -> (Vec<ProjectEntry>, Vec<ProjectEntry>) {
185    let reg = load_registry();
186    let mut valid = Vec::new();
187    let mut stale = Vec::new();
188
189    for entry in reg.projects.into_values() {
190        let edda_dir = Path::new(&entry.path).join(".edda");
191        if edda_dir.is_dir() {
192            valid.push(entry);
193        } else {
194            stale.push(entry);
195        }
196    }
197
198    (valid, stale)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    /// Run a closure with `EDDA_STORE_ROOT` pointing to an isolated tempdir.
205    fn with_isolated_store(f: impl FnOnce()) {
206        let _guard = crate::ENV_STORE_LOCK.lock().unwrap();
207        let store = tempfile::tempdir().unwrap();
208        std::env::set_var("EDDA_STORE_ROOT", store.path());
209        f();
210        std::env::remove_var("EDDA_STORE_ROOT");
211    }
212
213    #[test]
214    fn register_and_list_roundtrip() {
215        with_isolated_store(|| {
216            let tmp = tempfile::tempdir().unwrap();
217            std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
218
219            register_project(tmp.path()).unwrap();
220            let projects = list_projects();
221            let pid = project_id(tmp.path());
222
223            assert!(projects.iter().any(|p| p.project_id == pid));
224        });
225    }
226
227    #[test]
228    fn register_is_idempotent() {
229        with_isolated_store(|| {
230            let tmp = tempfile::tempdir().unwrap();
231            std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
232
233            register_project(tmp.path()).unwrap();
234            register_project(tmp.path()).unwrap();
235
236            let pid = project_id(tmp.path());
237            let reg = load_registry();
238            let count = reg
239                .projects
240                .values()
241                .filter(|p| p.project_id == pid)
242                .count();
243            assert_eq!(count, 1, "should not create duplicates");
244        });
245    }
246
247    #[test]
248    fn unregister_removes_entry() {
249        with_isolated_store(|| {
250            let tmp = tempfile::tempdir().unwrap();
251            let pid = project_id(tmp.path());
252
253            register_project(tmp.path()).unwrap();
254            assert!(get_project(&pid).is_some());
255
256            unregister_project(&pid).unwrap();
257            assert!(get_project(&pid).is_none());
258        });
259    }
260
261    #[test]
262    fn set_and_get_group() {
263        with_isolated_store(|| {
264            let tmp = tempfile::tempdir().unwrap();
265            std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
266            register_project(tmp.path()).unwrap();
267
268            assert!(project_group(tmp.path()).is_none());
269
270            set_project_group(tmp.path(), Some("team-a")).unwrap();
271            assert_eq!(project_group(tmp.path()), Some("team-a".to_string()));
272
273            set_project_group(tmp.path(), None).unwrap();
274            assert!(project_group(tmp.path()).is_none());
275        });
276    }
277
278    #[test]
279    fn list_group_members_returns_peers() {
280        with_isolated_store(|| {
281            let tmp1 = tempfile::tempdir().unwrap();
282            let tmp2 = tempfile::tempdir().unwrap();
283            std::fs::create_dir_all(tmp1.path().join(".edda")).unwrap();
284            std::fs::create_dir_all(tmp2.path().join(".edda")).unwrap();
285
286            register_project(tmp1.path()).unwrap();
287            register_project(tmp2.path()).unwrap();
288
289            set_project_group(tmp1.path(), Some("team-x")).unwrap();
290            set_project_group(tmp2.path(), Some("team-x")).unwrap();
291
292            let members = list_group_members(tmp1.path());
293            assert_eq!(members.len(), 1);
294            assert_eq!(members[0].project_id, project_id(tmp2.path()));
295
296            // No group = no members
297            let tmp3 = tempfile::tempdir().unwrap();
298            std::fs::create_dir_all(tmp3.path().join(".edda")).unwrap();
299            register_project(tmp3.path()).unwrap();
300            assert!(list_group_members(tmp3.path()).is_empty());
301        });
302    }
303
304    #[test]
305    fn list_groups_returns_all() {
306        with_isolated_store(|| {
307            let tmp1 = tempfile::tempdir().unwrap();
308            let tmp2 = tempfile::tempdir().unwrap();
309            std::fs::create_dir_all(tmp1.path().join(".edda")).unwrap();
310            std::fs::create_dir_all(tmp2.path().join(".edda")).unwrap();
311
312            register_project(tmp1.path()).unwrap();
313            register_project(tmp2.path()).unwrap();
314
315            set_project_group(tmp1.path(), Some("alpha")).unwrap();
316            set_project_group(tmp2.path(), Some("alpha")).unwrap();
317
318            let groups = list_groups();
319            assert_eq!(groups.len(), 1);
320            assert_eq!(groups["alpha"].len(), 2);
321        });
322    }
323
324    #[test]
325    fn group_backward_compat() {
326        // Simulating a registry.json without the group field
327        with_isolated_store(|| {
328            let json = r#"{"projects":{"abc":{"project_id":"abc","path":"/tmp/x","name":"x","registered_at":"2026-01-01","last_seen":"2026-01-01"}}}"#;
329            let reg: Registry = serde_json::from_str(json).unwrap();
330            let entry = reg.projects.get("abc").unwrap();
331            assert!(entry.group.is_none());
332        });
333    }
334
335    #[test]
336    fn validate_detects_stale() {
337        with_isolated_store(|| {
338            let tmp = tempfile::tempdir().unwrap();
339            std::fs::create_dir_all(tmp.path().join(".edda")).unwrap();
340            register_project(tmp.path()).unwrap();
341
342            let pid = project_id(tmp.path());
343            std::fs::remove_dir_all(tmp.path().join(".edda")).unwrap();
344
345            let (valid, stale) = validate_projects();
346            assert!(stale.iter().any(|p| p.project_id == pid));
347            assert!(!valid.iter().any(|p| p.project_id == pid));
348        });
349    }
350}