Skip to main content

synwire_storage/
registry.rs

1//! Global project registry.
2//!
3//! The registry tracks all Synwire-indexed projects across a product
4//! installation, persisted as `global/registry.json`.  Each entry records the
5//! worktree identity, last-access timestamp, and optional user-supplied tags.
6
7use crate::{StorageError, StorageLayout, WorktreeId};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::Path;
11
12/// A single entry in the project registry.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[non_exhaustive]
15pub struct RegistryEntry {
16    /// Stable worktree identifier.
17    pub worktree_id: WorktreeId,
18    /// Canonical root path of the worktree at the time of registration.
19    pub root_path: String,
20    /// RFC 3339 timestamp of the last access.
21    pub last_accessed_at: String,
22    /// User-supplied tags for filtering.
23    pub tags: Vec<String>,
24}
25
26/// In-memory representation of the global project registry.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct ProjectRegistry {
29    /// Entries keyed by the [`WorktreeId::key`].
30    pub entries: HashMap<String, RegistryEntry>,
31}
32
33impl ProjectRegistry {
34    /// Load the registry from `StorageLayout::global_registry()`.
35    ///
36    /// Returns an empty registry if the file does not exist.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`StorageError`] if the file exists but cannot be parsed.
41    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    /// Persist the registry to `StorageLayout::global_registry()`.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`StorageError`] if the parent directory cannot be created or
56    /// the file cannot be written.
57    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    /// Register or update an entry for the given worktree.
68    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    /// Update the last-access timestamp for a worktree (if registered).
82    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    /// Remove a worktree from the registry.
89    pub fn remove(&mut self, worktree: &WorktreeId) {
90        let _ = self.entries.remove(&worktree.key());
91    }
92
93    /// Add a tag to an existing entry.
94    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    /// Look up an entry by worktree key.
104    #[must_use]
105    pub fn get(&self, worktree: &WorktreeId) -> Option<&RegistryEntry> {
106        self.entries.get(&worktree.key())
107    }
108
109    /// Return all entries, sorted by `last_accessed_at` descending.
110    #[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        // Sleep briefly so timestamps differ.
169        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}