synwire_storage/
registry.rs1use crate::{StorageError, StorageLayout, WorktreeId};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[non_exhaustive]
15pub struct RegistryEntry {
16 pub worktree_id: WorktreeId,
18 pub root_path: String,
20 pub last_accessed_at: String,
22 pub tags: Vec<String>,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct ProjectRegistry {
29 pub entries: HashMap<String, RegistryEntry>,
31}
32
33impl ProjectRegistry {
34 pub fn load(layout: &StorageLayout) -> Result<Self, StorageError> {
42 let path = layout.global_registry();
43 if !path.exists() {
44 return Ok(Self::default());
45 }
46 let data = std::fs::read_to_string(&path)?;
47 let reg: Self = serde_json::from_str(&data)?;
48 Ok(reg)
49 }
50
51 pub fn save(&self, layout: &StorageLayout) -> Result<(), StorageError> {
58 let path = layout.global_registry();
59 if let Some(parent) = path.parent() {
60 std::fs::create_dir_all(parent)?;
61 }
62 let json = serde_json::to_string_pretty(self)?;
63 std::fs::write(&path, json)?;
64 Ok(())
65 }
66
67 pub fn upsert(&mut self, worktree: &WorktreeId, root_path: &Path) {
69 let key = worktree.key();
70 let _ = self.entries.insert(
71 key,
72 RegistryEntry {
73 worktree_id: worktree.clone(),
74 root_path: root_path.display().to_string(),
75 last_accessed_at: chrono::Utc::now().to_rfc3339(),
76 tags: Vec::new(),
77 },
78 );
79 }
80
81 pub fn touch(&mut self, worktree: &WorktreeId) {
83 if let Some(entry) = self.entries.get_mut(&worktree.key()) {
84 entry.last_accessed_at = chrono::Utc::now().to_rfc3339();
85 }
86 }
87
88 pub fn remove(&mut self, worktree: &WorktreeId) {
90 let _ = self.entries.remove(&worktree.key());
91 }
92
93 pub fn add_tag(&mut self, worktree: &WorktreeId, tag: impl Into<String>) {
95 let tag = tag.into();
96 if let Some(entry) = self.entries.get_mut(&worktree.key())
97 && !entry.tags.contains(&tag)
98 {
99 entry.tags.push(tag);
100 }
101 }
102
103 #[must_use]
105 pub fn get(&self, worktree: &WorktreeId) -> Option<&RegistryEntry> {
106 self.entries.get(&worktree.key())
107 }
108
109 #[must_use]
111 pub fn list_recent(&self) -> Vec<&RegistryEntry> {
112 let mut entries: Vec<_> = self.entries.values().collect();
113 entries.sort_by(|a, b| b.last_accessed_at.cmp(&a.last_accessed_at));
114 entries
115 }
116}
117
118#[cfg(test)]
119#[allow(clippy::expect_used, clippy::unwrap_used)]
120mod tests {
121 use super::*;
122 use crate::identity::RepoId;
123 use tempfile::tempdir;
124
125 fn dummy_worktree(name: &str) -> WorktreeId {
126 WorktreeId::from_parts(
127 RepoId::from_string(format!("repo-{name}")),
128 format!("{name}hash000000"),
129 format!("{name}@main"),
130 )
131 }
132
133 fn test_layout() -> (StorageLayout, tempfile::TempDir) {
134 let dir = tempdir().expect("tempdir");
135 let layout = StorageLayout::with_root(dir.path(), "synwire");
136 (layout, dir)
137 }
138
139 #[test]
140 fn empty_registry_when_absent() {
141 let (layout, _dir) = test_layout();
142 let reg = ProjectRegistry::load(&layout).expect("load");
143 assert!(reg.entries.is_empty());
144 }
145
146 #[test]
147 fn upsert_and_round_trip() {
148 let (layout, _dir) = test_layout();
149 let wid = dummy_worktree("a");
150 let root = std::path::PathBuf::from("/tmp/my-repo");
151 let mut reg = ProjectRegistry::load(&layout).expect("load");
152 reg.upsert(&wid, &root);
153 reg.save(&layout).expect("save");
154
155 let reg2 = ProjectRegistry::load(&layout).expect("reload");
156 assert!(reg2.get(&wid).is_some());
157 assert_eq!(reg2.get(&wid).expect("entry").root_path, "/tmp/my-repo");
158 }
159
160 #[test]
161 fn list_recent_orders_by_timestamp() {
162 let (_layout, _dir) = test_layout();
163 let wa = dummy_worktree("a");
164 let wb = dummy_worktree("b");
165 let root = std::path::PathBuf::from("/tmp");
166 let mut reg = ProjectRegistry::default();
167 reg.upsert(&wa, &root);
168 std::thread::sleep(std::time::Duration::from_millis(5));
170 reg.upsert(&wb, &root);
171 let recent = reg.list_recent();
172 assert_eq!(recent[0].worktree_id.key(), wb.key());
173 }
174
175 #[test]
176 fn tag_added_and_present() {
177 let wid = dummy_worktree("tagged");
178 let root = std::path::PathBuf::from("/tmp");
179 let mut reg = ProjectRegistry::default();
180 reg.upsert(&wid, &root);
181 reg.add_tag(&wid, "important");
182 assert!(
183 reg.get(&wid)
184 .expect("entry")
185 .tags
186 .contains(&"important".to_owned())
187 );
188 }
189
190 #[test]
191 fn remove_entry() {
192 let wid = dummy_worktree("rm");
193 let root = std::path::PathBuf::from("/tmp");
194 let mut reg = ProjectRegistry::default();
195 reg.upsert(&wid, &root);
196 reg.remove(&wid);
197 assert!(reg.get(&wid).is_none());
198 }
199}