intent_engine/
global_projects.rs1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10fn canonical_path_str(path: &Path) -> String {
17 path.canonicalize()
18 .unwrap_or_else(|_| path.to_path_buf())
19 .to_string_lossy()
20 .to_string()
21}
22
23const GLOBAL_DIR: &str = ".intent-engine";
24const PROJECTS_FILE: &str = "projects.json";
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ProjectEntry {
29 pub path: String,
31 pub last_accessed: DateTime<Utc>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub name: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct ProjectsRegistry {
41 pub projects: Vec<ProjectEntry>,
42}
43
44impl ProjectsRegistry {
45 pub fn registry_path() -> Option<PathBuf> {
47 dirs::home_dir().map(|h| h.join(GLOBAL_DIR).join(PROJECTS_FILE))
48 }
49
50 pub fn load() -> Self {
52 let Some(path) = Self::registry_path() else {
53 return Self::default();
54 };
55
56 if !path.exists() {
57 return Self::default();
58 }
59
60 match std::fs::read_to_string(&path) {
61 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
62 Err(_) => Self::default(),
63 }
64 }
65
66 pub fn save(&self) -> std::io::Result<()> {
68 let Some(path) = Self::registry_path() else {
69 return Ok(());
70 };
71
72 if let Some(parent) = path.parent() {
74 std::fs::create_dir_all(parent)?;
75 }
76
77 let content = serde_json::to_string_pretty(self)?;
78 std::fs::write(&path, content)
79 }
80
81 pub fn register_project(&mut self, project_path: &Path) {
87 let path_str = canonical_path_str(project_path);
88 let now = Utc::now();
89
90 if let Some(entry) = self.projects.iter_mut().find(|p| p.path == path_str) {
92 entry.last_accessed = now;
93 } else {
94 let name = project_path
96 .file_name()
97 .and_then(|n| n.to_str())
98 .map(|s| s.to_string());
99
100 self.projects.push(ProjectEntry {
101 path: path_str,
102 last_accessed: now,
103 name,
104 });
105 }
106 }
107
108 pub fn remove_project(&mut self, project_path: &str) -> bool {
113 let canonical = canonical_path_str(Path::new(project_path));
114 let initial_len = self.projects.len();
115 self.projects.retain(|p| p.path != canonical);
116 self.projects.len() < initial_len
117 }
118
119 pub fn get_projects(&self) -> Vec<&ProjectEntry> {
121 let mut projects: Vec<_> = self.projects.iter().collect();
122 projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
123 projects
124 }
125
126 pub fn validate_project(path: &str) -> bool {
128 let project_path = PathBuf::from(path);
129 let db_path = project_path.join(".intent-engine").join("project.db");
130 db_path.exists()
131 }
132}
133
134pub fn register_project(project_path: &Path) {
136 let mut registry = ProjectsRegistry::load();
137 registry.register_project(project_path);
138 if let Err(e) = registry.save() {
139 tracing::warn!(error = %e, "Failed to save global projects registry");
140 }
141}
142
143pub fn remove_project(project_path: &str) -> bool {
145 let mut registry = ProjectsRegistry::load();
146 let removed = registry.remove_project(project_path);
147 if removed {
148 if let Err(e) = registry.save() {
149 tracing::warn!(error = %e, "Failed to save global projects registry");
150 }
151 }
152 removed
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use tempfile::TempDir;
159
160 #[test]
161 fn test_project_entry_serialization() {
162 let entry = ProjectEntry {
163 path: "/test/project".to_string(),
164 last_accessed: Utc::now(),
165 name: Some("project".to_string()),
166 };
167
168 let json = serde_json::to_string(&entry).unwrap();
169 let parsed: ProjectEntry = serde_json::from_str(&json).unwrap();
170 assert_eq!(parsed.path, entry.path);
171 }
172
173 #[test]
174 fn test_registry_register_and_remove() {
175 let mut registry = ProjectsRegistry::default();
176
177 let temp = TempDir::new().unwrap();
179 registry.register_project(temp.path());
180 assert_eq!(registry.projects.len(), 1);
181
182 registry.register_project(temp.path());
184 assert_eq!(registry.projects.len(), 1);
185
186 let path_str = temp.path().to_string_lossy().to_string();
188 assert!(registry.remove_project(&path_str));
189 assert_eq!(registry.projects.len(), 0);
190 }
191
192 #[test]
193 fn test_registry_canonical_invariant() {
194 let mut registry = ProjectsRegistry::default();
197 let temp = TempDir::new().unwrap();
198
199 let canonical = temp.path().canonicalize().unwrap();
201 registry.register_project(&canonical);
202 assert_eq!(registry.projects.len(), 1);
203
204 registry.register_project(temp.path());
207 assert_eq!(registry.projects.len(), 1, "same project registered twice");
208
209 let raw_str = temp.path().to_string_lossy().to_string();
211 assert!(
212 registry.remove_project(&raw_str),
213 "remove via raw path must find canonical entry"
214 );
215 assert_eq!(registry.projects.len(), 0);
216 }
217
218 #[test]
219 fn test_registry_get_projects_sorted() {
220 let mut registry = ProjectsRegistry::default();
221
222 registry.projects.push(ProjectEntry {
224 path: "/old".to_string(),
225 last_accessed: Utc::now() - chrono::Duration::hours(2),
226 name: None,
227 });
228 registry.projects.push(ProjectEntry {
229 path: "/new".to_string(),
230 last_accessed: Utc::now(),
231 name: None,
232 });
233
234 let projects = registry.get_projects();
235 assert_eq!(projects[0].path, "/new");
236 assert_eq!(projects[1].path, "/old");
237 }
238}