Skip to main content

intent_engine/
global_projects.rs

1//! Global Projects Registry
2//!
3//! Manages a global list of all projects that have used Intent-Engine.
4//! This allows the Dashboard to show all known projects even when CLI is not running.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// Canonicalize a path to a consistent string representation.
11///
12/// On Windows, `Path::canonicalize()` prepends the `\\?\` extended-path
13/// prefix; without this helper two strings referring to the same physical
14/// path can compare unequal.  Falls back to the original string if the
15/// path does not exist yet (e.g. during registration of a new project).
16fn 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/// A registered project entry
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ProjectEntry {
29    /// Absolute path to the project root
30    pub path: String,
31    /// Last time this project was accessed via CLI
32    pub last_accessed: DateTime<Utc>,
33    /// Optional display name (defaults to directory name)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub name: Option<String>,
36}
37
38/// Global projects registry
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct ProjectsRegistry {
41    pub projects: Vec<ProjectEntry>,
42}
43
44impl ProjectsRegistry {
45    /// Get the path to the global projects file
46    pub fn registry_path() -> Option<PathBuf> {
47        dirs::home_dir().map(|h| h.join(GLOBAL_DIR).join(PROJECTS_FILE))
48    }
49
50    /// Load the registry from disk
51    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    /// Save the registry to disk
67    pub fn save(&self) -> std::io::Result<()> {
68        let Some(path) = Self::registry_path() else {
69            return Ok(());
70        };
71
72        // Ensure parent directory exists
73        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    /// Register or update a project.
82    ///
83    /// The path is canonicalized before storage so that the same physical
84    /// directory is never recorded twice regardless of how the caller spells
85    /// the path (relative vs absolute, Windows `\\?\` prefix, symlinks, etc).
86    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        // Check if project already exists
91        if let Some(entry) = self.projects.iter_mut().find(|p| p.path == path_str) {
92            entry.last_accessed = now;
93        } else {
94            // Add new project
95            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    /// Remove a project from the registry.
109    ///
110    /// The path is canonicalized before lookup so that the caller does not
111    /// need to know which form was used when the entry was registered.
112    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    /// Get all registered projects sorted by last_accessed (most recent first)
120    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    /// Check if a project exists and has a valid database
127    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
134/// Register a project in the global registry (convenience function)
135pub 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
143/// Remove a project from the global registry (convenience function)
144pub 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        // Register a project
178        let temp = TempDir::new().unwrap();
179        registry.register_project(temp.path());
180        assert_eq!(registry.projects.len(), 1);
181
182        // Register same project again (should update, not duplicate)
183        registry.register_project(temp.path());
184        assert_eq!(registry.projects.len(), 1);
185
186        // Remove project
187        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        // Both register and remove canonicalize internally, so the caller
195        // does not need to pass canonical paths for the operations to match.
196        let mut registry = ProjectsRegistry::default();
197        let temp = TempDir::new().unwrap();
198
199        // Register via canonical path (what canonicalize() would return)
200        let canonical = temp.path().canonicalize().unwrap();
201        registry.register_project(&canonical);
202        assert_eq!(registry.projects.len(), 1);
203
204        // Re-register via the original (possibly non-canonical) path —
205        // must be treated as the same project, not a duplicate.
206        registry.register_project(temp.path());
207        assert_eq!(registry.projects.len(), 1, "same project registered twice");
208
209        // Remove via the original path string — must find the canonical entry.
210        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        // Add projects with different timestamps
223        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}