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 DEFAULT_PORT: u16 = 11391; const VERSION: &str = "1.0";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ProjectRegistry {
13 pub version: String,
14 pub projects: Vec<RegisteredProject>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RegisteredProject {
20 pub path: PathBuf,
21 pub name: String,
22 pub port: u16,
23 pub pid: Option<u32>,
24 pub started_at: String,
25 pub db_path: PathBuf,
26
27 #[serde(default)]
29 pub mcp_connected: bool,
30 #[serde(default)]
31 pub mcp_last_seen: Option<String>,
32 #[serde(default)]
33 pub mcp_agent: Option<String>,
34}
35
36impl ProjectRegistry {
37 pub fn new() -> Self {
39 Self {
40 version: VERSION.to_string(),
41 projects: Vec::new(),
42 }
43 }
44
45 fn registry_path() -> Result<PathBuf> {
47 let home = dirs::home_dir().context("Failed to get home directory")?;
48 Ok(home.join(REGISTRY_FILE))
49 }
50
51 pub fn load() -> Result<Self> {
53 let path = Self::registry_path()?;
54
55 if !path.exists() {
56 if let Some(parent) = path.parent() {
58 fs::create_dir_all(parent).context("Failed to create registry directory")?;
59 }
60 return Ok(Self::new());
61 }
62
63 let content = fs::read_to_string(&path).context("Failed to read registry file")?;
64
65 let registry: Self =
66 serde_json::from_str(&content).context("Failed to parse registry JSON")?;
67
68 Ok(registry)
69 }
70
71 pub fn save(&self) -> Result<()> {
73 let path = Self::registry_path()?;
74
75 if let Some(parent) = path.parent() {
77 fs::create_dir_all(parent).context("Failed to create registry directory")?;
78 }
79
80 let content = serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
81
82 fs::write(&path, content).context("Failed to write registry file")?;
83
84 Ok(())
85 }
86
87 pub fn allocate_port(&mut self) -> Result<u16> {
89 let port = DEFAULT_PORT;
91
92 if Self::is_port_available(port) {
94 Ok(port)
95 } else {
96 anyhow::bail!(
97 "Port {} is already in use. Please stop the existing Dashboard instance first.",
98 port
99 )
100 }
101 }
102
103 pub fn is_port_available(port: u16) -> bool {
105 use std::net::TcpListener;
106 TcpListener::bind(("127.0.0.1", port)).is_ok()
107 }
108
109 pub fn register(&mut self, project: RegisteredProject) {
111 self.unregister(&project.path);
113 self.projects.push(project);
114 }
115
116 pub fn unregister(&mut self, path: &PathBuf) {
118 self.projects.retain(|p| p.path != *path);
119 }
120
121 pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
123 self.projects.iter().find(|p| p.path == *path)
124 }
125
126 pub fn find_by_path_mut(&mut self, path: &PathBuf) -> Option<&mut RegisteredProject> {
128 self.projects.iter_mut().find(|p| p.path == *path)
129 }
130
131 pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
133 self.projects.iter().find(|p| p.port == port)
134 }
135
136 pub fn list_all(&self) -> &[RegisteredProject] {
138 &self.projects
139 }
140
141 pub fn register_mcp_connection(
144 &mut self,
145 path: &PathBuf,
146 agent_name: Option<String>,
147 ) -> anyhow::Result<()> {
148 let now = chrono::Utc::now().to_rfc3339();
149
150 if let Some(project) = self.find_by_path_mut(path) {
152 project.mcp_connected = true;
154 project.mcp_last_seen = Some(now.clone());
155 project.mcp_agent = agent_name;
156 } else {
157 let name = path
159 .file_name()
160 .and_then(|n| n.to_str())
161 .unwrap_or("unknown")
162 .to_string();
163
164 let db_path = path.join(".intent-engine").join("project.db");
165
166 let project = RegisteredProject {
167 path: path.clone(),
168 name,
169 port: 0, pid: None,
171 started_at: now.clone(),
172 db_path,
173 mcp_connected: true,
174 mcp_last_seen: Some(now),
175 mcp_agent: agent_name,
176 };
177
178 self.projects.push(project);
179 }
180
181 self.save()
182 }
183
184 pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
186 if let Some(project) = self.find_by_path_mut(path) {
187 project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
188 project.mcp_connected = true;
189 self.save()?;
190 }
191 Ok(())
192 }
193
194 pub fn unregister_mcp_connection(&mut self, path: &PathBuf) -> anyhow::Result<()> {
196 if let Some(project) = self.find_by_path_mut(path) {
197 project.mcp_connected = false;
198 project.mcp_last_seen = None;
199 project.mcp_agent = None;
200
201 self.save()?;
204 }
205 Ok(())
206 }
207
208 pub fn cleanup_dead_processes(&mut self) {
210 self.projects.retain(|project| {
211 if let Some(pid) = project.pid {
212 Self::is_process_alive(pid)
213 } else {
214 true }
216 });
217 }
218
219 pub fn cleanup_stale_mcp_connections(&mut self) {
221 use chrono::DateTime;
222 let now = chrono::Utc::now();
223 const TIMEOUT_MINUTES: i64 = 5;
224
225 for project in &mut self.projects {
226 if let Some(last_seen) = &project.mcp_last_seen {
227 if let Ok(last_time) = DateTime::parse_from_rfc3339(last_seen) {
228 let duration = now.signed_duration_since(last_time.with_timezone(&chrono::Utc));
229 if duration.num_minutes() > TIMEOUT_MINUTES {
230 project.mcp_connected = false;
231 project.mcp_last_seen = None;
232 project.mcp_agent = None;
233 }
234 }
235 }
236 }
237
238 self.projects.retain(|p| p.port != 0 || p.mcp_connected);
240 }
241
242 #[cfg(unix)]
244 fn is_process_alive(pid: u32) -> bool {
245 use std::process::Command;
246 Command::new("kill")
247 .args(["-0", &pid.to_string()])
248 .output()
249 .map(|output| output.status.success())
250 .unwrap_or(false)
251 }
252
253 #[cfg(windows)]
254 fn is_process_alive(pid: u32) -> bool {
255 use std::process::Command;
256 Command::new("tasklist")
257 .args(["/FI", &format!("PID eq {}", pid)])
258 .output()
259 .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
260 .unwrap_or(false)
261 }
262}
263
264impl Default for ProjectRegistry {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use tempfile::TempDir;
274
275 #[test]
276 fn test_new_registry() {
277 let registry = ProjectRegistry::new();
278 assert_eq!(registry.version, VERSION);
279 assert_eq!(registry.projects.len(), 0);
280 }
281
282 #[test]
283 #[serial_test::serial]
284 fn test_allocate_port() {
285 let mut registry = ProjectRegistry::new();
286
287 match registry.allocate_port() {
289 Ok(port) => {
290 assert_eq!(port, DEFAULT_PORT);
292
293 registry.register(RegisteredProject {
295 path: PathBuf::from("/test/project1"),
296 name: "project1".to_string(),
297 port,
298 pid: None,
299 started_at: "2025-01-01T00:00:00Z".to_string(),
300 db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
301 mcp_connected: false,
302 mcp_last_seen: None,
303 mcp_agent: None,
304 });
305 },
306 Err(e) => {
307 assert!(
309 e.to_string().contains("already in use"),
310 "Expected 'already in use' error, got: {}",
311 e
312 );
313 },
314 }
315 }
316
317 #[test]
318 fn test_register_and_find() {
319 let mut registry = ProjectRegistry::new();
320
321 let project = RegisteredProject {
322 path: PathBuf::from("/test/project"),
323 name: "test-project".to_string(),
324 port: 11391,
325 pid: Some(12345),
326 started_at: "2025-01-01T00:00:00Z".to_string(),
327 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
328 mcp_connected: false,
329 mcp_last_seen: None,
330 mcp_agent: None,
331 };
332
333 registry.register(project.clone());
334 assert_eq!(registry.projects.len(), 1);
335
336 let found = registry.find_by_path(&PathBuf::from("/test/project"));
338 assert!(found.is_some());
339 assert_eq!(found.unwrap().name, "test-project");
340
341 let found_by_port = registry.find_by_port(11391);
343 assert!(found_by_port.is_some());
344 assert_eq!(found_by_port.unwrap().name, "test-project");
345 }
346
347 #[test]
348 fn test_unregister() {
349 let mut registry = ProjectRegistry::new();
350
351 let project = RegisteredProject {
352 path: PathBuf::from("/test/project"),
353 name: "test-project".to_string(),
354 port: 11391,
355 pid: None,
356 started_at: "2025-01-01T00:00:00Z".to_string(),
357 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
358 mcp_connected: false,
359 mcp_last_seen: None,
360 mcp_agent: None,
361 };
362
363 registry.register(project.clone());
364 assert_eq!(registry.projects.len(), 1);
365
366 registry.unregister(&PathBuf::from("/test/project"));
367 assert_eq!(registry.projects.len(), 0);
368 }
369
370 #[test]
371 fn test_duplicate_path_replaces() {
372 let mut registry = ProjectRegistry::new();
373
374 let project1 = RegisteredProject {
375 path: PathBuf::from("/test/project"),
376 name: "project-v1".to_string(),
377 port: 11391,
378 pid: None,
379 started_at: "2025-01-01T00:00:00Z".to_string(),
380 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
381 mcp_connected: false,
382 mcp_last_seen: None,
383 mcp_agent: None,
384 };
385
386 let project2 = RegisteredProject {
387 path: PathBuf::from("/test/project"),
388 name: "project-v2".to_string(),
389 port: 3031,
390 pid: None,
391 started_at: "2025-01-01T01:00:00Z".to_string(),
392 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
393 mcp_connected: false,
394 mcp_last_seen: None,
395 mcp_agent: None,
396 };
397
398 registry.register(project1);
399 assert_eq!(registry.projects.len(), 1);
400
401 registry.register(project2);
402 assert_eq!(registry.projects.len(), 1);
403
404 let found = registry.find_by_path(&PathBuf::from("/test/project"));
405 assert_eq!(found.unwrap().name, "project-v2");
406 }
407
408 #[test]
409 fn test_save_and_load() {
410 let _temp_dir = TempDir::new().unwrap();
411
412 let mut registry = ProjectRegistry::new();
414
415 let project = RegisteredProject {
416 path: PathBuf::from("/test/project"),
417 name: "test-project".to_string(),
418 port: 11391,
419 pid: Some(12345),
420 started_at: "2025-01-01T00:00:00Z".to_string(),
421 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
422 mcp_connected: false,
423 mcp_last_seen: None,
424 mcp_agent: None,
425 };
426
427 registry.register(project);
428
429 let json = serde_json::to_string_pretty(®istry).unwrap();
431 assert!(json.contains("test-project"));
432 assert!(json.contains("11391"));
433
434 let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
436 assert_eq!(loaded.projects.len(), 1);
437 assert_eq!(loaded.projects[0].name, "test-project");
438 assert_eq!(loaded.projects[0].port, 11391);
439 }
440
441 #[test]
442 #[serial_test::serial]
443 fn test_fixed_port() {
444 let mut registry = ProjectRegistry::new();
445
446 match registry.allocate_port() {
448 Ok(port) => {
449 assert_eq!(port, DEFAULT_PORT);
451 },
452 Err(e) => {
453 assert!(
455 e.to_string().contains("already in use"),
456 "Expected 'already in use' error, got: {}",
457 e
458 );
459 },
460 }
461 }
462}