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#[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#[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 #[serde(default)]
31 pub mcp_connected: bool,
32 #[serde(default)]
33 pub mcp_last_seen: Option<String>,
34 #[serde(default)]
35 pub mcp_agent: Option<String>,
36}
37
38impl ProjectRegistry {
39 pub fn new() -> Self {
41 Self {
42 version: VERSION.to_string(),
43 projects: Vec::new(),
44 next_port: MIN_PORT,
45 }
46 }
47
48 fn registry_path() -> Result<PathBuf> {
50 let home = dirs::home_dir().context("Failed to get home directory")?;
51 Ok(home.join(REGISTRY_FILE))
52 }
53
54 pub fn load() -> Result<Self> {
56 let path = Self::registry_path()?;
57
58 if !path.exists() {
59 if let Some(parent) = path.parent() {
61 fs::create_dir_all(parent).context("Failed to create registry directory")?;
62 }
63 return Ok(Self::new());
64 }
65
66 let content = fs::read_to_string(&path).context("Failed to read registry file")?;
67
68 let registry: Self =
69 serde_json::from_str(&content).context("Failed to parse registry JSON")?;
70
71 Ok(registry)
72 }
73
74 pub fn save(&self) -> Result<()> {
76 let path = Self::registry_path()?;
77
78 if let Some(parent) = path.parent() {
80 fs::create_dir_all(parent).context("Failed to create registry directory")?;
81 }
82
83 let content = serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
84
85 fs::write(&path, content).context("Failed to write registry file")?;
86
87 Ok(())
88 }
89
90 pub fn allocate_port(&mut self) -> Result<u16> {
92 let mut port = self.next_port;
94 let mut attempts = 0;
95 const MAX_ATTEMPTS: usize = 70; while attempts < MAX_ATTEMPTS {
98 if port > MAX_PORT {
99 port = MIN_PORT;
100 }
101
102 if !self.projects.iter().any(|p| p.port == port) {
104 if Self::is_port_available(port) {
106 self.next_port = if port == MAX_PORT { MIN_PORT } else { port + 1 };
107 return Ok(port);
108 }
109 }
110
111 port += 1;
112 attempts += 1;
113 }
114
115 anyhow::bail!("No available ports in range {}-{}", MIN_PORT, MAX_PORT)
116 }
117
118 pub fn is_port_available(port: u16) -> bool {
120 use std::net::TcpListener;
121 TcpListener::bind(("127.0.0.1", port)).is_ok()
122 }
123
124 pub fn register(&mut self, project: RegisteredProject) {
126 self.unregister(&project.path);
128 self.projects.push(project);
129 }
130
131 pub fn unregister(&mut self, path: &PathBuf) {
133 self.projects.retain(|p| p.path != *path);
134 }
135
136 pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
138 self.projects.iter().find(|p| p.path == *path)
139 }
140
141 pub fn find_by_path_mut(&mut self, path: &PathBuf) -> Option<&mut RegisteredProject> {
143 self.projects.iter_mut().find(|p| p.path == *path)
144 }
145
146 pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
148 self.projects.iter().find(|p| p.port == port)
149 }
150
151 pub fn list_all(&self) -> &[RegisteredProject] {
153 &self.projects
154 }
155
156 pub fn register_mcp_connection(
158 &mut self,
159 path: &PathBuf,
160 agent_name: Option<String>,
161 ) -> anyhow::Result<()> {
162 let now = chrono::Utc::now().to_rfc3339();
163
164 if let Some(project) = self.find_by_path_mut(path) {
166 project.mcp_connected = true;
168 project.mcp_last_seen = Some(now.clone());
169 project.mcp_agent = agent_name;
170 } else {
171 let name = path
173 .file_name()
174 .and_then(|n| n.to_str())
175 .unwrap_or("unknown")
176 .to_string();
177
178 let db_path = path.join(".intent-engine").join("intents.db");
179
180 let project = RegisteredProject {
181 path: path.clone(),
182 name,
183 port: 0, pid: None,
185 started_at: now.clone(),
186 db_path,
187 mcp_connected: true,
188 mcp_last_seen: Some(now),
189 mcp_agent: agent_name,
190 };
191
192 self.projects.push(project);
193 }
194
195 self.save()
196 }
197
198 pub fn update_mcp_heartbeat(&mut self, path: &PathBuf) -> anyhow::Result<()> {
200 if let Some(project) = self.find_by_path_mut(path) {
201 project.mcp_last_seen = Some(chrono::Utc::now().to_rfc3339());
202 project.mcp_connected = true;
203 self.save()?;
204 }
205 Ok(())
206 }
207
208 pub fn unregister_mcp_connection(&mut self, path: &PathBuf) -> anyhow::Result<()> {
210 if let Some(project) = self.find_by_path_mut(path) {
211 project.mcp_connected = false;
212 project.mcp_last_seen = None;
213 project.mcp_agent = None;
214
215 if project.port == 0 {
217 self.unregister(path);
218 }
219
220 self.save()?;
221 }
222 Ok(())
223 }
224
225 pub fn cleanup_dead_processes(&mut self) {
227 self.projects.retain(|project| {
228 if let Some(pid) = project.pid {
229 Self::is_process_alive(pid)
230 } else {
231 true }
233 });
234 }
235
236 pub fn cleanup_stale_mcp_connections(&mut self) {
238 use chrono::DateTime;
239 let now = chrono::Utc::now();
240 const TIMEOUT_MINUTES: i64 = 5;
241
242 for project in &mut self.projects {
243 if let Some(last_seen) = &project.mcp_last_seen {
244 if let Ok(last_time) = DateTime::parse_from_rfc3339(last_seen) {
245 let duration = now.signed_duration_since(last_time.with_timezone(&chrono::Utc));
246 if duration.num_minutes() > TIMEOUT_MINUTES {
247 project.mcp_connected = false;
248 project.mcp_last_seen = None;
249 project.mcp_agent = None;
250 }
251 }
252 }
253 }
254
255 self.projects.retain(|p| p.port != 0 || p.mcp_connected);
257 }
258
259 #[cfg(unix)]
261 fn is_process_alive(pid: u32) -> bool {
262 use std::process::Command;
263 Command::new("kill")
264 .args(["-0", &pid.to_string()])
265 .output()
266 .map(|output| output.status.success())
267 .unwrap_or(false)
268 }
269
270 #[cfg(windows)]
271 fn is_process_alive(pid: u32) -> bool {
272 use std::process::Command;
273 Command::new("tasklist")
274 .args(["/FI", &format!("PID eq {}", pid)])
275 .output()
276 .map(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
277 .unwrap_or(false)
278 }
279}
280
281impl Default for ProjectRegistry {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use tempfile::TempDir;
291
292 #[test]
293 fn test_new_registry() {
294 let registry = ProjectRegistry::new();
295 assert_eq!(registry.version, VERSION);
296 assert_eq!(registry.projects.len(), 0);
297 assert_eq!(registry.next_port, MIN_PORT);
298 }
299
300 #[test]
301 fn test_allocate_port() {
302 let mut registry = ProjectRegistry::new();
303
304 let port1 = registry.allocate_port().unwrap();
306 assert_eq!(port1, MIN_PORT);
307 assert_eq!(registry.next_port, MIN_PORT + 1);
308
309 registry.register(RegisteredProject {
311 path: PathBuf::from("/test/project1"),
312 name: "project1".to_string(),
313 port: port1,
314 pid: None,
315 started_at: "2025-01-01T00:00:00Z".to_string(),
316 db_path: PathBuf::from("/test/project1/.intent-engine/intents.db"),
317 mcp_connected: false,
318 mcp_last_seen: None,
319 mcp_agent: None,
320 });
321
322 let port2 = registry.allocate_port().unwrap();
324 assert_eq!(port2, MIN_PORT + 1);
325 }
326
327 #[test]
328 fn test_register_and_find() {
329 let mut registry = ProjectRegistry::new();
330
331 let project = RegisteredProject {
332 path: PathBuf::from("/test/project"),
333 name: "test-project".to_string(),
334 port: 3030,
335 pid: Some(12345),
336 started_at: "2025-01-01T00:00:00Z".to_string(),
337 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
338 mcp_connected: false,
339 mcp_last_seen: None,
340 mcp_agent: None,
341 };
342
343 registry.register(project.clone());
344 assert_eq!(registry.projects.len(), 1);
345
346 let found = registry.find_by_path(&PathBuf::from("/test/project"));
348 assert!(found.is_some());
349 assert_eq!(found.unwrap().name, "test-project");
350
351 let found_by_port = registry.find_by_port(3030);
353 assert!(found_by_port.is_some());
354 assert_eq!(found_by_port.unwrap().name, "test-project");
355 }
356
357 #[test]
358 fn test_unregister() {
359 let mut registry = ProjectRegistry::new();
360
361 let project = RegisteredProject {
362 path: PathBuf::from("/test/project"),
363 name: "test-project".to_string(),
364 port: 3030,
365 pid: None,
366 started_at: "2025-01-01T00:00:00Z".to_string(),
367 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
368 mcp_connected: false,
369 mcp_last_seen: None,
370 mcp_agent: None,
371 };
372
373 registry.register(project.clone());
374 assert_eq!(registry.projects.len(), 1);
375
376 registry.unregister(&PathBuf::from("/test/project"));
377 assert_eq!(registry.projects.len(), 0);
378 }
379
380 #[test]
381 fn test_duplicate_path_replaces() {
382 let mut registry = ProjectRegistry::new();
383
384 let project1 = RegisteredProject {
385 path: PathBuf::from("/test/project"),
386 name: "project-v1".to_string(),
387 port: 3030,
388 pid: None,
389 started_at: "2025-01-01T00:00:00Z".to_string(),
390 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
391 mcp_connected: false,
392 mcp_last_seen: None,
393 mcp_agent: None,
394 };
395
396 let project2 = RegisteredProject {
397 path: PathBuf::from("/test/project"),
398 name: "project-v2".to_string(),
399 port: 3031,
400 pid: None,
401 started_at: "2025-01-01T01:00:00Z".to_string(),
402 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
403 mcp_connected: false,
404 mcp_last_seen: None,
405 mcp_agent: None,
406 };
407
408 registry.register(project1);
409 assert_eq!(registry.projects.len(), 1);
410
411 registry.register(project2);
412 assert_eq!(registry.projects.len(), 1);
413
414 let found = registry.find_by_path(&PathBuf::from("/test/project"));
415 assert_eq!(found.unwrap().name, "project-v2");
416 }
417
418 #[test]
419 fn test_save_and_load() {
420 let _temp_dir = TempDir::new().unwrap();
421
422 let mut registry = ProjectRegistry::new();
424
425 let project = RegisteredProject {
426 path: PathBuf::from("/test/project"),
427 name: "test-project".to_string(),
428 port: 3030,
429 pid: Some(12345),
430 started_at: "2025-01-01T00:00:00Z".to_string(),
431 db_path: PathBuf::from("/test/project/.intent-engine/intents.db"),
432 mcp_connected: false,
433 mcp_last_seen: None,
434 mcp_agent: None,
435 };
436
437 registry.register(project);
438
439 let json = serde_json::to_string_pretty(®istry).unwrap();
441 assert!(json.contains("test-project"));
442 assert!(json.contains("3030"));
443
444 let loaded: ProjectRegistry = serde_json::from_str(&json).unwrap();
446 assert_eq!(loaded.projects.len(), 1);
447 assert_eq!(loaded.projects[0].name, "test-project");
448 assert_eq!(loaded.projects[0].port, 3030);
449 }
450
451 #[test]
452 fn test_port_wraparound() {
453 let mut registry = ProjectRegistry::new();
454 registry.next_port = MAX_PORT;
455
456 let port = registry.allocate_port().unwrap();
458 assert_eq!(port, MAX_PORT);
459
460 assert_eq!(registry.next_port, MIN_PORT);
462 }
463}