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
10const GLOBAL_DIR: &str = ".intent-engine";
11const PROJECTS_FILE: &str = "projects.json";
12
13/// A registered project entry
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ProjectEntry {
16    /// Absolute path to the project root
17    pub path: String,
18    /// Last time this project was accessed via CLI
19    pub last_accessed: DateTime<Utc>,
20    /// Optional display name (defaults to directory name)
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub name: Option<String>,
23}
24
25/// Global projects registry
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct ProjectsRegistry {
28    pub projects: Vec<ProjectEntry>,
29}
30
31impl ProjectsRegistry {
32    /// Get the path to the global projects file
33    pub fn registry_path() -> Option<PathBuf> {
34        dirs::home_dir().map(|h| h.join(GLOBAL_DIR).join(PROJECTS_FILE))
35    }
36
37    /// Load the registry from disk
38    pub fn load() -> Self {
39        let Some(path) = Self::registry_path() else {
40            return Self::default();
41        };
42
43        if !path.exists() {
44            return Self::default();
45        }
46
47        match std::fs::read_to_string(&path) {
48            Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
49            Err(_) => Self::default(),
50        }
51    }
52
53    /// Save the registry to disk
54    pub fn save(&self) -> std::io::Result<()> {
55        let Some(path) = Self::registry_path() else {
56            return Ok(());
57        };
58
59        // Ensure parent directory exists
60        if let Some(parent) = path.parent() {
61            std::fs::create_dir_all(parent)?;
62        }
63
64        let content = serde_json::to_string_pretty(self)?;
65        std::fs::write(&path, content)
66    }
67
68    /// Register or update a project
69    pub fn register_project(&mut self, project_path: &Path) {
70        let path_str = project_path.to_string_lossy().to_string();
71        let now = Utc::now();
72
73        // Check if project already exists
74        if let Some(entry) = self.projects.iter_mut().find(|p| p.path == path_str) {
75            entry.last_accessed = now;
76        } else {
77            // Add new project
78            let name = project_path
79                .file_name()
80                .and_then(|n| n.to_str())
81                .map(|s| s.to_string());
82
83            self.projects.push(ProjectEntry {
84                path: path_str,
85                last_accessed: now,
86                name,
87            });
88        }
89    }
90
91    /// Remove a project from the registry
92    pub fn remove_project(&mut self, project_path: &str) -> bool {
93        let initial_len = self.projects.len();
94        self.projects.retain(|p| p.path != project_path);
95        self.projects.len() < initial_len
96    }
97
98    /// Get all registered projects sorted by last_accessed (most recent first)
99    pub fn get_projects(&self) -> Vec<&ProjectEntry> {
100        let mut projects: Vec<_> = self.projects.iter().collect();
101        projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
102        projects
103    }
104
105    /// Check if a project exists and has a valid database
106    pub fn validate_project(path: &str) -> bool {
107        let project_path = PathBuf::from(path);
108        let db_path = project_path.join(".intent-engine").join("project.db");
109        db_path.exists()
110    }
111}
112
113/// Register a project in the global registry (convenience function)
114pub fn register_project(project_path: &Path) {
115    let mut registry = ProjectsRegistry::load();
116    registry.register_project(project_path);
117    if let Err(e) = registry.save() {
118        tracing::warn!(error = %e, "Failed to save global projects registry");
119    }
120}
121
122/// Remove a project from the global registry (convenience function)
123pub fn remove_project(project_path: &str) -> bool {
124    let mut registry = ProjectsRegistry::load();
125    let removed = registry.remove_project(project_path);
126    if removed {
127        if let Err(e) = registry.save() {
128            tracing::warn!(error = %e, "Failed to save global projects registry");
129        }
130    }
131    removed
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use tempfile::TempDir;
138
139    #[test]
140    fn test_project_entry_serialization() {
141        let entry = ProjectEntry {
142            path: "/test/project".to_string(),
143            last_accessed: Utc::now(),
144            name: Some("project".to_string()),
145        };
146
147        let json = serde_json::to_string(&entry).unwrap();
148        let parsed: ProjectEntry = serde_json::from_str(&json).unwrap();
149        assert_eq!(parsed.path, entry.path);
150    }
151
152    #[test]
153    fn test_registry_register_and_remove() {
154        let mut registry = ProjectsRegistry::default();
155
156        // Register a project
157        let temp = TempDir::new().unwrap();
158        registry.register_project(temp.path());
159        assert_eq!(registry.projects.len(), 1);
160
161        // Register same project again (should update, not duplicate)
162        registry.register_project(temp.path());
163        assert_eq!(registry.projects.len(), 1);
164
165        // Remove project
166        let path_str = temp.path().to_string_lossy().to_string();
167        assert!(registry.remove_project(&path_str));
168        assert_eq!(registry.projects.len(), 0);
169    }
170
171    #[test]
172    fn test_registry_get_projects_sorted() {
173        let mut registry = ProjectsRegistry::default();
174
175        // Add projects with different timestamps
176        registry.projects.push(ProjectEntry {
177            path: "/old".to_string(),
178            last_accessed: Utc::now() - chrono::Duration::hours(2),
179            name: None,
180        });
181        registry.projects.push(ProjectEntry {
182            path: "/new".to_string(),
183            last_accessed: Utc::now(),
184            name: None,
185        });
186
187        let projects = registry.get_projects();
188        assert_eq!(projects[0].path, "/new");
189        assert_eq!(projects[1].path, "/old");
190    }
191}