intent_engine/dashboard/
registry.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6const REGISTRY_FILE: &str = ".intent-engine/projects.json";
7const MIN_PORT: u16 = 3030;
8const MAX_PORT: u16 = 3099;
9const VERSION: &str = "1.0";
10
11/// Global project registry for managing multiple Dashboard instances
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ProjectRegistry {
14    pub version: String,
15    pub projects: Vec<RegisteredProject>,
16    pub next_port: u16,
17}
18
19/// A registered project with Dashboard instance
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RegisteredProject {
22    pub path: PathBuf,
23    pub name: String,
24    pub port: u16,
25    pub pid: Option<u32>,
26    pub started_at: String,
27    pub db_path: PathBuf,
28}
29
30impl ProjectRegistry {
31    /// Create a new empty registry
32    pub fn new() -> Self {
33        Self {
34            version: VERSION.to_string(),
35            projects: Vec::new(),
36            next_port: MIN_PORT,
37        }
38    }
39
40    /// Get the registry file path
41    fn registry_path() -> Result<PathBuf> {
42        let home = dirs::home_dir().context("Failed to get home directory")?;
43        Ok(home.join(REGISTRY_FILE))
44    }
45
46    /// Load registry from file, or create new if doesn't exist
47    pub fn load() -> Result<Self> {
48        let path = Self::registry_path()?;
49
50        if !path.exists() {
51            // Create parent directory if needed
52            if let Some(parent) = path.parent() {
53                fs::create_dir_all(parent).context("Failed to create registry directory")?;
54            }
55            return Ok(Self::new());
56        }
57
58        let content = fs::read_to_string(&path).context("Failed to read registry file")?;
59
60        let registry: Self =
61            serde_json::from_str(&content).context("Failed to parse registry JSON")?;
62
63        Ok(registry)
64    }
65
66    /// Save registry to file
67    pub fn save(&self) -> Result<()> {
68        let path = Self::registry_path()?;
69
70        // Create parent directory if needed
71        if let Some(parent) = path.parent() {
72            fs::create_dir_all(parent).context("Failed to create registry directory")?;
73        }
74
75        let content = serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
76
77        fs::write(&path, content).context("Failed to write registry file")?;
78
79        Ok(())
80    }
81
82    /// Allocate a new port, checking for conflicts
83    pub fn allocate_port(&mut self) -> Result<u16> {
84        // Try next_port first
85        let mut port = self.next_port;
86        let mut attempts = 0;
87        const MAX_ATTEMPTS: usize = 70; // Total available ports
88
89        while attempts < MAX_ATTEMPTS {
90            if port > MAX_PORT {
91                port = MIN_PORT;
92            }
93
94            // Check if port is already in use
95            if !self.projects.iter().any(|p| p.port == port) {
96                // Check if port is actually available on the system
97                if Self::is_port_available(port) {
98                    self.next_port = if port == MAX_PORT { MIN_PORT } else { port + 1 };
99                    return Ok(port);
100                }
101            }
102
103            port += 1;
104            attempts += 1;
105        }
106
107        anyhow::bail!("No available ports in range {}-{}", MIN_PORT, MAX_PORT)
108    }
109
110    /// Check if a port is available on the system
111    pub fn is_port_available(port: u16) -> bool {
112        use std::net::TcpListener;
113        TcpListener::bind(("127.0.0.1", port)).is_ok()
114    }
115
116    /// Register a new project
117    pub fn register(&mut self, project: RegisteredProject) {
118        // Remove existing entry for the same path if exists
119        self.unregister(&project.path);
120        self.projects.push(project);
121    }
122
123    /// Unregister a project by path
124    pub fn unregister(&mut self, path: &PathBuf) {
125        self.projects.retain(|p| p.path != *path);
126    }
127
128    /// Find project by path
129    pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
130        self.projects.iter().find(|p| p.path == *path)
131    }
132
133    /// Find project by path (mutable)
134    pub fn find_by_path_mut(&mut self, path: &PathBuf) -> Option<&mut RegisteredProject> {
135        self.projects.iter_mut().find(|p| p.path == *path)
136    }
137
138    /// Find project by port
139    pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
140        self.projects.iter().find(|p| p.port == port)
141    }
142
143    /// Get all registered projects
144    pub fn list_all(&self) -> &[RegisteredProject] {
145        &self.projects
146    }
147
148    /// Clean up projects with dead PIDs
149    pub fn cleanup_dead_processes(&mut self) {
150        self.projects.retain(|project| {
151            if let Some(pid) = project.pid {
152                Self::is_process_alive(pid)
153            } else {
154                true // Keep projects without PID
155            }
156        });
157    }
158
159    /// Check if a process is alive
160    #[cfg(unix)]
161    fn is_process_alive(pid: u32) -> bool {
162        use std::process::Command;
163        Command::new("kill")
164            .args(["-0", &pid.to_string()])
165            .output()
166            .map(|output| output.status.success())
167            .unwrap_or(false)
168    }
169
170    #[cfg(windows)]
171    fn is_process_alive(pid: u32) -> bool {
172        use std::process::Command;
173        Command::new("tasklist")
174            .args(["/FI", &format!("PID eq {}", pid)])
175            .output()
176            .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
177            .unwrap_or(false)
178    }
179}
180
181impl Default for ProjectRegistry {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use tempfile::TempDir;
191
192    #[test]
193    fn test_new_registry() {
194        let registry = ProjectRegistry::new();
195        assert_eq!(registry.version, VERSION);
196        assert_eq!(registry.projects.len(), 0);
197        assert_eq!(registry.next_port, MIN_PORT);
198    }
199
200    #[test]
201    fn test_allocate_port() {
202        let mut registry = ProjectRegistry::new();
203
204        // Allocate first port
205        let port1 = registry.allocate_port().unwrap();
206        assert_eq!(port1, MIN_PORT);
207        assert_eq!(registry.next_port, MIN_PORT + 1);
208
209        // Register a project with that port
210        registry.register(RegisteredProject {
211            path: PathBuf::from("/test/project1"),
212            name: "project1".to_string(),
213            port: port1,
214            pid: None,
215            started_at: "2025-01-01T00:00:00Z".to_string(),
216            db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
217        });
218
219        // Allocate second port
220        let port2 = registry.allocate_port().unwrap();
221        assert_eq!(port2, MIN_PORT + 1);
222    }
223
224    #[test]
225    fn test_register_and_find() {
226        let mut registry = ProjectRegistry::new();
227
228        let project = RegisteredProject {
229            path: PathBuf::from("/test/project"),
230            name: "test-project".to_string(),
231            port: 3030,
232            pid: Some(12345),
233            started_at: "2025-01-01T00:00:00Z".to_string(),
234            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
235        };
236
237        registry.register(project.clone());
238        assert_eq!(registry.projects.len(), 1);
239
240        // Find by path
241        let found = registry.find_by_path(&PathBuf::from("/test/project"));
242        assert!(found.is_some());
243        assert_eq!(found.unwrap().name, "test-project");
244
245        // Find by port
246        let found_by_port = registry.find_by_port(3030);
247        assert!(found_by_port.is_some());
248        assert_eq!(found_by_port.unwrap().name, "test-project");
249    }
250
251    #[test]
252    fn test_unregister() {
253        let mut registry = ProjectRegistry::new();
254
255        let project = RegisteredProject {
256            path: PathBuf::from("/test/project"),
257            name: "test-project".to_string(),
258            port: 3030,
259            pid: None,
260            started_at: "2025-01-01T00:00:00Z".to_string(),
261            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
262        };
263
264        registry.register(project.clone());
265        assert_eq!(registry.projects.len(), 1);
266
267        registry.unregister(&PathBuf::from("/test/project"));
268        assert_eq!(registry.projects.len(), 0);
269    }
270
271    #[test]
272    fn test_duplicate_path_replaces() {
273        let mut registry = ProjectRegistry::new();
274
275        let project1 = RegisteredProject {
276            path: PathBuf::from("/test/project"),
277            name: "project-v1".to_string(),
278            port: 3030,
279            pid: None,
280            started_at: "2025-01-01T00:00:00Z".to_string(),
281            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
282        };
283
284        let project2 = RegisteredProject {
285            path: PathBuf::from("/test/project"),
286            name: "project-v2".to_string(),
287            port: 3031,
288            pid: None,
289            started_at: "2025-01-01T01:00:00Z".to_string(),
290            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
291        };
292
293        registry.register(project1);
294        assert_eq!(registry.projects.len(), 1);
295
296        registry.register(project2);
297        assert_eq!(registry.projects.len(), 1);
298
299        let found = registry.find_by_path(&PathBuf::from("/test/project"));
300        assert_eq!(found.unwrap().name, "project-v2");
301    }
302
303    #[test]
304    fn test_save_and_load() {
305        let _temp_dir = TempDir::new().unwrap();
306
307        // We can't easily override home_dir in tests, so we'll test serialization manually
308        let mut registry = ProjectRegistry::new();
309
310        let project = RegisteredProject {
311            path: PathBuf::from("/test/project"),
312            name: "test-project".to_string(),
313            port: 3030,
314            pid: Some(12345),
315            started_at: "2025-01-01T00:00:00Z".to_string(),
316            db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
317        };
318
319        registry.register(project);
320
321        // Test serialization
322        let json = serde_json::to_string_pretty(&registry).unwrap();
323        assert!(json.contains("test-project"));
324        assert!(json.contains("3030"));
325
326        // Test deserialization
327        let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
328        assert_eq!(loaded.projects.len(), 1);
329        assert_eq!(loaded.projects[0].name, "test-project");
330        assert_eq!(loaded.projects[0].port, 3030);
331    }
332
333    #[test]
334    fn test_port_wraparound() {
335        let mut registry = ProjectRegistry::new();
336        registry.next_port = MAX_PORT;
337
338        // Allocate port at max
339        let port = registry.allocate_port().unwrap();
340        assert_eq!(port, MAX_PORT);
341
342        // Next allocation should wrap to MIN_PORT
343        assert_eq!(registry.next_port, MIN_PORT);
344    }
345}