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
30impl ProjectRegistry {
31 pub fn new() -> Self {
33 Self {
34 version: VERSION.to_string(),
35 projects: Vec::new(),
36 next_port: MIN_PORT,
37 }
38 }
39
40 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 pub fn load() -> Result<Self> {
48 let path = Self::registry_path()?;
49
50 if !path.exists() {
51 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 pub fn save(&self) -> Result<()> {
68 let path = Self::registry_path()?;
69
70 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 pub fn allocate_port(&mut self) -> Result<u16> {
84 let mut port = self.next_port;
86 let mut attempts = 0;
87 const MAX_ATTEMPTS: usize = 70; while attempts < MAX_ATTEMPTS {
90 if port > MAX_PORT {
91 port = MIN_PORT;
92 }
93
94 if !self.projects.iter().any(|p| p.port == port) {
96 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 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 pub fn register(&mut self, project: RegisteredProject) {
118 self.unregister(&project.path);
120 self.projects.push(project);
121 }
122
123 pub fn unregister(&mut self, path: &PathBuf) {
125 self.projects.retain(|p| p.path != *path);
126 }
127
128 pub fn find_by_path(&self, path: &PathBuf) -> Option<&RegisteredProject> {
130 self.projects.iter().find(|p| p.path == *path)
131 }
132
133 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 pub fn find_by_port(&self, port: u16) -> Option<&RegisteredProject> {
140 self.projects.iter().find(|p| p.port == port)
141 }
142
143 pub fn list_all(&self) -> &[RegisteredProject] {
145 &self.projects
146 }
147
148 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 }
156 });
157 }
158
159 #[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 let port1 = registry.allocate_port().unwrap();
206 assert_eq!(port1, MIN_PORT);
207 assert_eq!(registry.next_port, MIN_PORT + 1);
208
209 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 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 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 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 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 let json = serde_json::to_string_pretty(®istry).unwrap();
323 assert!(json.contains("test-project"));
324 assert!(json.contains("3030"));
325
326 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 let port = registry.allocate_port().unwrap();
340 assert_eq!(port, MAX_PORT);
341
342 assert_eq!(registry.next_port, MIN_PORT);
344 }
345}