Skip to main content

ryo_storage/storage/
project.rs

1//! Project index for tracking imported projects.
2//!
3//! Maintains a registry of projects imported into Ryo's management.
4//!
5//! # Server Management
6//!
7//! Each project has an associated socket path for its ryo server.
8//! Socket path is derived from project_id: `/tmp/ryo-{project_id[..8]}.sock`
9//!
10//! # Per-Project Server Options
11//!
12//! Projects can override global server settings via `server_options`:
13//! - `watch`: Enable file watching for auto-reload
14//! - `watch_debounce_ms`: Debounce duration for file watcher
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20/// Per-project server options (overrides global config)
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ProjectServerOptions {
24    /// Watch for file changes and auto-reload (None = use global default)
25    pub watch: Option<bool>,
26
27    /// Debounce duration for file watcher in milliseconds (None = use global default)
28    pub watch_debounce_ms: Option<u64>,
29}
30
31/// Check if a process is alive by PID.
32#[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    // On non-unix, assume alive (conservative)
45    true
46}
47
48/// Metadata for an imported project.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProjectMeta {
51    /// Unique project identifier (UUID).
52    pub project_id: String,
53    /// Display name of the project.
54    pub name: String,
55    /// Project path on disk.
56    pub path: PathBuf,
57    /// When the project was imported (ISO 8601).
58    pub imported_at: String,
59    /// Last time the project was accessed.
60    pub last_accessed: String,
61    /// Number of files in the project.
62    pub file_count: usize,
63    /// Total lines of code.
64    pub total_lines: usize,
65    /// Description (from ryo.toml or auto-generated).
66    pub description: Option<String>,
67    /// Tags for categorization.
68    #[serde(default)]
69    pub tags: Vec<String>,
70    /// Whether ryo.toml exists.
71    pub has_config: bool,
72    /// Unix socket path for this project's server.
73    /// Generated from project_id: /tmp/ryo-{project_id[..8]}.sock
74    #[serde(default)]
75    pub socket: Option<PathBuf>,
76    /// Process ID of the running server (None if not running).
77    #[serde(default)]
78    pub server_pid: Option<u32>,
79    /// Per-project server options (overrides global config).
80    #[serde(default)]
81    pub server_options: ProjectServerOptions,
82}
83
84impl ProjectMeta {
85    /// Create new project metadata.
86    ///
87    /// The path is automatically canonicalized to ensure consistent lookups.
88    /// If canonicalization fails (e.g., path doesn't exist), the original path is used.
89    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        // Canonicalize path for consistent lookups
99        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    /// Generate socket path from project ID.
118    /// Format: /tmp/ryo-{project_id[..8]}.sock
119    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    /// Get socket path, generating if not set.
125    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    /// Check if server is running (process exists).
132    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    /// Set server PID.
141    pub fn set_server_pid(&mut self, pid: Option<u32>) {
142        self.server_pid = pid;
143    }
144
145    /// Clear server PID if process is dead.
146    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    /// Update last accessed time.
157    pub fn touch(&mut self) {
158        self.last_accessed = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
159    }
160
161    /// Add a description.
162    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
163        self.description = Some(desc.into());
164        self
165    }
166
167    /// Add tags.
168    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
169        self.tags = tags;
170        self
171    }
172
173    /// Set config presence.
174    pub fn with_config(mut self, has_config: bool) -> Self {
175        self.has_config = has_config;
176        self
177    }
178}
179
180/// Index of all imported projects.
181#[derive(Debug, Clone, Default, Serialize)]
182pub struct ProjectIndex {
183    /// All projects, keyed by project ID.
184    projects: HashMap<String, ProjectMeta>,
185    /// Projects indexed by path for fast lookup.
186    #[serde(skip)]
187    by_path: HashMap<PathBuf, String>,
188    /// Index version for future migrations.
189    #[serde(default = "default_version")]
190    version: u32,
191}
192
193fn default_version() -> u32 {
194    1
195}
196
197impl ProjectIndex {
198    /// Create a new empty index.
199    pub fn new() -> Self {
200        Self {
201            projects: HashMap::new(),
202            by_path: HashMap::new(),
203            version: 1,
204        }
205    }
206
207    /// Add a project to the index.
208    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    /// Remove a project from the index.
217    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    /// Get a project by ID.
227    ///
228    /// Supports both full UUID and short ID prefix (minimum 4 characters).
229    pub fn get(&self, project_id: &str) -> Option<&ProjectMeta> {
230        // Try exact match first
231        if let Some(meta) = self.projects.get(project_id) {
232            return Some(meta);
233        }
234
235        // Try prefix match for short IDs (minimum 4 chars to avoid ambiguity)
236        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    /// Get a mutable project by ID.
252    ///
253    /// Supports both full UUID and short ID prefix (minimum 4 characters).
254    pub fn get_mut(&mut self, project_id: &str) -> Option<&mut ProjectMeta> {
255        // Try exact match first
256        if self.projects.contains_key(project_id) {
257            return self.projects.get_mut(project_id);
258        }
259
260        // Try prefix match for short IDs
261        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                // Verify it's the only match
270                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    /// Get a project by path.
285    ///
286    /// The input path is canonicalized before lookup to ensure consistent matching.
287    pub fn get_by_path(&self, path: &Path) -> Option<&ProjectMeta> {
288        // Try with canonicalized path first
289        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        // Fall back to direct lookup (for paths that don't exist on disk)
296        self.by_path.get(path).and_then(|id| self.projects.get(id))
297    }
298
299    /// Check if a project with the given path exists.
300    ///
301    /// The input path is canonicalized before lookup.
302    pub fn contains_path(&self, path: &Path) -> bool {
303        // Try with canonicalized path first
304        if let Ok(canonical) = path.canonicalize() {
305            if self.by_path.contains_key(&canonical) {
306                return true;
307            }
308        }
309
310        // Fall back to direct lookup
311        self.by_path.contains_key(path)
312    }
313
314    /// List all projects, sorted by last accessed (most recent first).
315    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    /// List projects by import date (newest first).
322    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    /// Search projects by name pattern.
329    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    /// Search projects by tags.
338    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    /// Count total projects.
346    pub fn count(&self) -> usize {
347        self.projects.len()
348    }
349
350    /// Rebuild the by_path index (call after deserialization).
351    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    /// Cleanup all dead server PIDs.
359    ///
360    /// Checks each project's server_pid and clears it if the process is no longer running.
361    /// Returns the number of projects that were cleaned up.
362    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    /// Get total lines of code across all projects.
373    pub fn total_lines(&self) -> usize {
374        self.projects.values().map(|p| p.total_lines).sum()
375    }
376
377    /// Get total file count across all projects.
378    pub fn total_files(&self) -> usize {
379        self.projects.values().map(|p| p.file_count).sum()
380    }
381}
382
383// Custom deserialize to rebuild path index
384impl<'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}