1use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ProjectServerOptions {
24 pub watch: Option<bool>,
26
27 pub watch_debounce_ms: Option<u64>,
29}
30
31#[cfg(unix)]
33pub fn is_process_alive(pid: u32) -> bool {
34 use std::process::Command;
35 Command::new("kill")
36 .args(["-0", &pid.to_string()])
37 .output()
38 .map(|o| o.status.success())
39 .unwrap_or(false)
40}
41
42#[cfg(not(unix))]
43pub fn is_process_alive(_pid: u32) -> bool {
44 true
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProjectMeta {
51 pub project_id: String,
53 pub name: String,
55 pub path: PathBuf,
57 pub imported_at: String,
59 pub last_accessed: String,
61 pub file_count: usize,
63 pub total_lines: usize,
65 pub description: Option<String>,
67 #[serde(default)]
69 pub tags: Vec<String>,
70 pub has_config: bool,
72 #[serde(default)]
75 pub socket: Option<PathBuf>,
76 #[serde(default)]
78 pub server_pid: Option<u32>,
79 #[serde(default)]
81 pub server_options: ProjectServerOptions,
82}
83
84impl ProjectMeta {
85 pub fn new(
90 project_id: String,
91 name: String,
92 path: PathBuf,
93 file_count: usize,
94 total_lines: usize,
95 ) -> Self {
96 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
97 let socket = Self::generate_socket_path(&project_id);
98 let canonical_path = path.canonicalize().unwrap_or(path);
100 Self {
101 project_id,
102 name,
103 path: canonical_path,
104 imported_at: now.clone(),
105 last_accessed: now,
106 file_count,
107 total_lines,
108 description: None,
109 tags: Vec::new(),
110 has_config: false,
111 socket: Some(socket),
112 server_pid: None,
113 server_options: ProjectServerOptions::default(),
114 }
115 }
116
117 pub fn generate_socket_path(project_id: &str) -> PathBuf {
120 let short_id = &project_id[..8.min(project_id.len())];
121 PathBuf::from(format!("/tmp/ryo-{}.sock", short_id))
122 }
123
124 pub fn socket_path(&self) -> PathBuf {
126 self.socket
127 .clone()
128 .unwrap_or_else(|| Self::generate_socket_path(&self.project_id))
129 }
130
131 pub fn is_server_running(&self) -> bool {
133 if let Some(pid) = self.server_pid {
134 is_process_alive(pid)
135 } else {
136 false
137 }
138 }
139
140 pub fn set_server_pid(&mut self, pid: Option<u32>) {
142 self.server_pid = pid;
143 }
144
145 pub fn cleanup_dead_server(&mut self) -> bool {
147 if let Some(pid) = self.server_pid {
148 if !is_process_alive(pid) {
149 self.server_pid = None;
150 return true;
151 }
152 }
153 false
154 }
155
156 pub fn touch(&mut self) {
158 self.last_accessed = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
159 }
160
161 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
163 self.description = Some(desc.into());
164 self
165 }
166
167 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
169 self.tags = tags;
170 self
171 }
172
173 pub fn with_config(mut self, has_config: bool) -> Self {
175 self.has_config = has_config;
176 self
177 }
178}
179
180#[derive(Debug, Clone, Default, Serialize)]
182pub struct ProjectIndex {
183 projects: HashMap<String, ProjectMeta>,
185 #[serde(skip)]
187 by_path: HashMap<PathBuf, String>,
188 #[serde(default = "default_version")]
190 version: u32,
191}
192
193fn default_version() -> u32 {
194 1
195}
196
197impl ProjectIndex {
198 pub fn new() -> Self {
200 Self {
201 projects: HashMap::new(),
202 by_path: HashMap::new(),
203 version: 1,
204 }
205 }
206
207 pub fn add(&mut self, meta: ProjectMeta) {
209 let project_id = meta.project_id.clone();
210 let path = meta.path.clone();
211
212 self.projects.insert(project_id.clone(), meta);
213 self.by_path.insert(path, project_id);
214 }
215
216 pub fn remove(&mut self, project_id: &str) -> Option<ProjectMeta> {
218 if let Some(meta) = self.projects.remove(project_id) {
219 self.by_path.remove(&meta.path);
220 Some(meta)
221 } else {
222 None
223 }
224 }
225
226 pub fn get(&self, project_id: &str) -> Option<&ProjectMeta> {
230 if let Some(meta) = self.projects.get(project_id) {
232 return Some(meta);
233 }
234
235 if project_id.len() >= 4 {
237 let matches: Vec<_> = self
238 .projects
239 .iter()
240 .filter(|(id, _)| id.starts_with(project_id))
241 .collect();
242
243 if matches.len() == 1 {
244 return Some(matches[0].1);
245 }
246 }
247
248 None
249 }
250
251 pub fn get_mut(&mut self, project_id: &str) -> Option<&mut ProjectMeta> {
255 if self.projects.contains_key(project_id) {
257 return self.projects.get_mut(project_id);
258 }
259
260 if project_id.len() >= 4 {
262 let matching_id = self
263 .projects
264 .keys()
265 .find(|id| id.starts_with(project_id))
266 .cloned();
267
268 if let Some(id) = matching_id {
269 let count = self
271 .projects
272 .keys()
273 .filter(|k| k.starts_with(project_id))
274 .count();
275 if count == 1 {
276 return self.projects.get_mut(&id);
277 }
278 }
279 }
280
281 None
282 }
283
284 pub fn get_by_path(&self, path: &Path) -> Option<&ProjectMeta> {
288 if let Ok(canonical) = path.canonicalize() {
290 if let Some(id) = self.by_path.get(&canonical) {
291 return self.projects.get(id);
292 }
293 }
294
295 self.by_path.get(path).and_then(|id| self.projects.get(id))
297 }
298
299 pub fn contains_path(&self, path: &Path) -> bool {
303 if let Ok(canonical) = path.canonicalize() {
305 if self.by_path.contains_key(&canonical) {
306 return true;
307 }
308 }
309
310 self.by_path.contains_key(path)
312 }
313
314 pub fn list(&self) -> Vec<&ProjectMeta> {
316 let mut projects: Vec<_> = self.projects.values().collect();
317 projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
318 projects
319 }
320
321 pub fn list_by_import_date(&self) -> Vec<&ProjectMeta> {
323 let mut projects: Vec<_> = self.projects.values().collect();
324 projects.sort_by(|a, b| b.imported_at.cmp(&a.imported_at));
325 projects
326 }
327
328 pub fn search_by_name(&self, pattern: &str) -> Vec<&ProjectMeta> {
330 let pattern_lower = pattern.to_lowercase();
331 self.projects
332 .values()
333 .filter(|p| p.name.to_lowercase().contains(&pattern_lower))
334 .collect()
335 }
336
337 pub fn search_by_tags(&self, tags: &[String]) -> Vec<&ProjectMeta> {
339 self.projects
340 .values()
341 .filter(|p| tags.iter().any(|t| p.tags.contains(t)))
342 .collect()
343 }
344
345 pub fn count(&self) -> usize {
347 self.projects.len()
348 }
349
350 pub fn rebuild_path_index(&mut self) {
352 self.by_path.clear();
353 for (project_id, meta) in &self.projects {
354 self.by_path.insert(meta.path.clone(), project_id.clone());
355 }
356 }
357
358 pub fn cleanup_dead_servers(&mut self) -> usize {
363 let mut cleaned = 0;
364 for meta in self.projects.values_mut() {
365 if meta.cleanup_dead_server() {
366 cleaned += 1;
367 }
368 }
369 cleaned
370 }
371
372 pub fn total_lines(&self) -> usize {
374 self.projects.values().map(|p| p.total_lines).sum()
375 }
376
377 pub fn total_files(&self) -> usize {
379 self.projects.values().map(|p| p.file_count).sum()
380 }
381}
382
383impl<'de> serde::de::Deserialize<'de> for ProjectIndex {
385 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
386 where
387 D: serde::de::Deserializer<'de>,
388 {
389 #[derive(Deserialize)]
390 struct IndexData {
391 projects: HashMap<String, ProjectMeta>,
392 #[serde(default = "default_version")]
393 version: u32,
394 }
395
396 let data = IndexData::deserialize(deserializer)?;
397 let mut index = ProjectIndex {
398 projects: data.projects,
399 by_path: HashMap::new(),
400 version: data.version,
401 };
402 index.rebuild_path_index();
403 Ok(index)
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn create_test_meta(id: &str, name: &str, path: &str) -> ProjectMeta {
412 ProjectMeta {
413 project_id: id.to_string(),
414 name: name.to_string(),
415 path: PathBuf::from(path),
416 imported_at: "2024-01-01T10:00:00Z".to_string(),
417 last_accessed: "2024-01-01T10:00:00Z".to_string(),
418 file_count: 10,
419 total_lines: 500,
420 description: None,
421 tags: Vec::new(),
422 has_config: false,
423 socket: Some(ProjectMeta::generate_socket_path(id)),
424 server_pid: None,
425 server_options: ProjectServerOptions::default(),
426 }
427 }
428
429 #[test]
430 fn test_add_and_get() {
431 let mut index = ProjectIndex::new();
432 let meta = create_test_meta("p1", "MyProject", "/projects/my-project");
433 index.add(meta);
434
435 assert!(index.get("p1").is_some());
436 assert!(index.get("p2").is_none());
437 }
438
439 #[test]
440 fn test_get_by_path() {
441 let mut index = ProjectIndex::new();
442 index.add(create_test_meta("p1", "MyProject", "/projects/my-project"));
443
444 assert!(index
445 .get_by_path(Path::new("/projects/my-project"))
446 .is_some());
447 assert!(index.get_by_path(Path::new("/other/path")).is_none());
448 }
449
450 #[test]
451 fn test_remove() {
452 let mut index = ProjectIndex::new();
453 index.add(create_test_meta("p1", "MyProject", "/projects/my-project"));
454
455 let removed = index.remove("p1");
456 assert!(removed.is_some());
457 assert!(index.get("p1").is_none());
458 assert!(!index.contains_path(Path::new("/projects/my-project")));
459 }
460
461 #[test]
462 fn test_search_by_name() {
463 let mut index = ProjectIndex::new();
464 index.add(create_test_meta("p1", "TodoApp", "/projects/todo"));
465 index.add(create_test_meta("p2", "WebServer", "/projects/web"));
466 index.add(create_test_meta("p3", "TodoBackend", "/projects/todo-be"));
467
468 let results = index.search_by_name("todo");
469 assert_eq!(results.len(), 2);
470 }
471
472 #[test]
473 fn test_serialization_roundtrip() {
474 let mut index = ProjectIndex::new();
475 index.add(create_test_meta("p1", "Project1", "/path/1"));
476 index.add(create_test_meta("p2", "Project2", "/path/2"));
477
478 let json = serde_json::to_string(&index).unwrap();
479 let restored: ProjectIndex = serde_json::from_str(&json).unwrap();
480
481 assert_eq!(restored.count(), 2);
482 assert!(restored.get("p1").is_some());
483 assert!(restored.get_by_path(Path::new("/path/2")).is_some());
484 }
485}