1use 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub group: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct Registry {
28 pub projects: BTreeMap<String, ProjectEntry>,
29}
30
31pub fn registry_path() -> PathBuf {
33 store_root().join("registry.json")
34}
35
36fn registry_lock_path() -> PathBuf {
38 store_root().join("registry.lock")
39}
40
41fn 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
50fn save_registry(reg: &Registry) -> anyhow::Result<()> {
52 let json = serde_json::to_string_pretty(reg)?;
53 write_atomic(®istry_path(), json.as_bytes())
54}
55
56fn 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
63fn 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
71pub fn register_project(repo_root: &Path) -> anyhow::Result<()> {
73 let _lock = lock_file(®istry_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 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(®)
97}
98
99pub fn unregister_project(pid: &str) -> anyhow::Result<()> {
101 let _lock = lock_file(®istry_lock_path())?;
102 let mut reg = load_registry();
103 reg.projects.remove(pid);
104 save_registry(®)
105}
106
107pub fn list_projects() -> Vec<ProjectEntry> {
109 let reg = load_registry();
110 reg.projects.into_values().collect()
111}
112
113pub fn get_project(pid: &str) -> Option<ProjectEntry> {
115 let reg = load_registry();
116 reg.projects.get(pid).cloned()
117}
118
119pub fn touch_project(repo_root: &Path) -> anyhow::Result<()> {
121 let _lock = lock_file(®istry_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(®)?;
128 }
129
130 Ok(())
131}
132
133pub fn set_project_group(repo_root: &Path, group: Option<&str>) -> anyhow::Result<()> {
135 let _lock = lock_file(®istry_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(®)
142 } else {
143 anyhow::bail!("project not registered: {pid}")
144 }
145}
146
147pub 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
154pub 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
169pub 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
182pub 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 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 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 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}