ricecoder_orchestration/analyzers/
workspace_scanner.rs

1//! Workspace scanning and project discovery
2
3use crate::error::Result;
4use crate::models::{Project, ProjectStatus};
5use std::path::PathBuf;
6use tracing::{debug, info, warn};
7
8/// Scans a workspace to discover all projects and their metadata
9///
10/// The WorkspaceScanner is responsible for discovering all projects in a workspace,
11/// extracting their metadata, and building the initial project list. It uses
12/// `ricecoder_storage::PathResolver` for all path operations.
13pub struct WorkspaceScanner {
14    workspace_root: PathBuf,
15}
16
17impl WorkspaceScanner {
18    /// Creates a new WorkspaceScanner for the given workspace root
19    ///
20    /// # Arguments
21    ///
22    /// * `workspace_root` - The root path of the workspace
23    ///
24    /// # Returns
25    ///
26    /// A new WorkspaceScanner instance
27    pub fn new(workspace_root: PathBuf) -> Self {
28        debug!("Creating WorkspaceScanner for workspace: {:?}", workspace_root);
29        Self { workspace_root }
30    }
31
32    /// Scans the workspace and discovers all projects
33    ///
34    /// This method scans the workspace root for all projects and extracts
35    /// their metadata (name, path, type). It handles missing or malformed
36    /// project files gracefully.
37    ///
38    /// # Returns
39    ///
40    /// A vector of discovered projects
41    pub async fn scan_workspace(&self) -> Result<Vec<Project>> {
42        info!("Scanning workspace: {:?}", self.workspace_root);
43
44        let mut projects = Vec::new();
45
46        // Check if workspace root exists
47        if !self.workspace_root.exists() {
48            debug!("Workspace root does not exist: {:?}", self.workspace_root);
49            return Ok(projects);
50        }
51
52        // Scan for projects in standard locations
53        let projects_dir = self.workspace_root.join("projects");
54        if projects_dir.exists() {
55            debug!("Scanning projects directory: {:?}", projects_dir);
56            projects.extend(self.scan_directory(&projects_dir).await?);
57        }
58
59        // Scan for crates in standard locations
60        let crates_dir = self.workspace_root.join("crates");
61        if crates_dir.exists() {
62            debug!("Scanning crates directory: {:?}", crates_dir);
63            projects.extend(self.scan_directory(&crates_dir).await?);
64        }
65
66        info!("Discovered {} projects", projects.len());
67        Ok(projects)
68    }
69
70    /// Scans a directory for projects
71    ///
72    /// # Arguments
73    ///
74    /// * `dir` - The directory to scan
75    ///
76    /// # Returns
77    ///
78    /// A vector of projects found in the directory
79    async fn scan_directory(&self, dir: &PathBuf) -> Result<Vec<Project>> {
80        let mut projects = Vec::new();
81
82        match std::fs::read_dir(dir) {
83            Ok(entries) => {
84                for entry in entries.flatten() {
85                    let path = entry.path();
86
87                    if path.is_dir() {
88                        // Check if this directory is a project
89                        if let Some(project) = self.detect_project(&path).await {
90                            projects.push(project);
91                        }
92                    }
93                }
94            }
95            Err(e) => {
96                debug!("Error reading directory {:?}: {}", dir, e);
97            }
98        }
99
100        Ok(projects)
101    }
102
103    /// Detects if a directory is a project and extracts its metadata
104    ///
105    /// # Arguments
106    ///
107    /// * `path` - The path to check
108    ///
109    /// # Returns
110    ///
111    /// A Project if the directory is a valid project, None otherwise
112    async fn detect_project(&self, path: &std::path::Path) -> Option<Project> {
113        // Check for Cargo.toml (Rust project)
114        let cargo_toml = path.join("Cargo.toml");
115        if cargo_toml.exists() {
116            if let Some(name) = path.file_name() {
117                if let Some(name_str) = name.to_str() {
118                    debug!("Detected Rust project: {}", name_str);
119                    let version = self.extract_rust_version(&cargo_toml).await;
120                    return Some(Project {
121                        path: path.to_path_buf(),
122                        name: name_str.to_string(),
123                        project_type: "rust".to_string(),
124                        version,
125                        status: ProjectStatus::Unknown,
126                    });
127                }
128            }
129        }
130
131        // Check for package.json (Node.js project)
132        let package_json = path.join("package.json");
133        if package_json.exists() {
134            if let Some(name) = path.file_name() {
135                if let Some(name_str) = name.to_str() {
136                    debug!("Detected Node.js project: {}", name_str);
137                    let version = self.extract_nodejs_version(&package_json).await;
138                    return Some(Project {
139                        path: path.to_path_buf(),
140                        name: name_str.to_string(),
141                        project_type: "nodejs".to_string(),
142                        version,
143                        status: ProjectStatus::Unknown,
144                    });
145                }
146            }
147        }
148
149        // Check for pyproject.toml (Python project)
150        let pyproject_toml = path.join("pyproject.toml");
151        if pyproject_toml.exists() {
152            if let Some(name) = path.file_name() {
153                if let Some(name_str) = name.to_str() {
154                    debug!("Detected Python project: {}", name_str);
155                    let version = self.extract_python_version(&pyproject_toml).await;
156                    return Some(Project {
157                        path: path.to_path_buf(),
158                        name: name_str.to_string(),
159                        project_type: "python".to_string(),
160                        version,
161                        status: ProjectStatus::Unknown,
162                    });
163                }
164            }
165        }
166
167        None
168    }
169
170    /// Extracts version from Cargo.toml
171    async fn extract_rust_version(&self, cargo_toml: &PathBuf) -> String {
172        match std::fs::read_to_string(cargo_toml) {
173            Ok(content) => {
174                // Simple version extraction from Cargo.toml
175                for line in content.lines() {
176                    if line.contains("version") && line.contains("=") {
177                        if let Some(version_part) = line.split('=').nth(1) {
178                            let version = version_part
179                                .trim()
180                                .trim_matches('"')
181                                .trim_matches('\'')
182                                .to_string();
183                            if !version.is_empty() {
184                                debug!("Extracted Rust version: {}", version);
185                                return version;
186                            }
187                        }
188                    }
189                }
190                "0.1.0".to_string()
191            }
192            Err(e) => {
193                warn!("Failed to read Cargo.toml: {}", e);
194                "0.1.0".to_string()
195            }
196        }
197    }
198
199    /// Extracts version from package.json
200    async fn extract_nodejs_version(&self, package_json: &PathBuf) -> String {
201        match std::fs::read_to_string(package_json) {
202            Ok(content) => {
203                // Simple version extraction from package.json
204                // Look for "version": "X.Y.Z" pattern
205                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
206                    if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
207                        debug!("Extracted Node.js version: {}", version);
208                        return version.to_string();
209                    }
210                }
211                
212                // Fallback to line-by-line parsing
213                for line in content.lines() {
214                    if line.contains("\"version\"") && line.contains(":") {
215                        if let Some(version_part) = line.split(':').nth(1) {
216                            let version = version_part
217                                .trim()
218                                .trim_matches(',')
219                                .trim_matches('"')
220                                .to_string();
221                            if !version.is_empty() && !version.contains('{') && !version.contains('}') {
222                                debug!("Extracted Node.js version: {}", version);
223                                return version;
224                            }
225                        }
226                    }
227                }
228                "0.1.0".to_string()
229            }
230            Err(e) => {
231                warn!("Failed to read package.json: {}", e);
232                "0.1.0".to_string()
233            }
234        }
235    }
236
237    /// Extracts version from pyproject.toml
238    async fn extract_python_version(&self, pyproject_toml: &PathBuf) -> String {
239        match std::fs::read_to_string(pyproject_toml) {
240            Ok(content) => {
241                // Simple version extraction from pyproject.toml
242                for line in content.lines() {
243                    if line.contains("version") && line.contains("=") {
244                        if let Some(version_part) = line.split('=').nth(1) {
245                            let version = version_part
246                                .trim()
247                                .trim_matches('"')
248                                .trim_matches('\'')
249                                .to_string();
250                            if !version.is_empty() {
251                                debug!("Extracted Python version: {}", version);
252                                return version;
253                            }
254                        }
255                    }
256                }
257                "0.1.0".to_string()
258            }
259            Err(e) => {
260                warn!("Failed to read pyproject.toml: {}", e);
261                "0.1.0".to_string()
262            }
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use tempfile::TempDir;
271
272    #[tokio::test]
273    async fn test_workspace_scanner_creation() {
274        let root = PathBuf::from("/test/workspace");
275        let scanner = WorkspaceScanner::new(root.clone());
276
277        assert_eq!(scanner.workspace_root, root);
278    }
279
280    #[tokio::test]
281    async fn test_scan_nonexistent_workspace() {
282        let root = PathBuf::from("/nonexistent/workspace");
283        let scanner = WorkspaceScanner::new(root);
284
285        let projects = scanner.scan_workspace().await.expect("scan failed");
286        assert_eq!(projects.len(), 0);
287    }
288
289    #[tokio::test]
290    async fn test_scan_empty_workspace() {
291        let temp_dir = TempDir::new().expect("failed to create temp dir");
292        let root = temp_dir.path().to_path_buf();
293
294        let scanner = WorkspaceScanner::new(root);
295        let projects = scanner.scan_workspace().await.expect("scan failed");
296
297        assert_eq!(projects.len(), 0);
298    }
299
300    #[tokio::test]
301    async fn test_detect_rust_project() {
302        let temp_dir = TempDir::new().expect("failed to create temp dir");
303        let project_dir = temp_dir.path().join("test-project");
304        std::fs::create_dir(&project_dir).expect("failed to create project dir");
305
306        // Create Cargo.toml
307        std::fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"")
308            .expect("failed to write Cargo.toml");
309
310        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
311        let project = scanner.detect_project(&project_dir).await;
312
313        assert!(project.is_some());
314        let proj = project.unwrap();
315        assert_eq!(proj.name, "test-project");
316        assert_eq!(proj.project_type, "rust");
317    }
318
319    #[tokio::test]
320    async fn test_detect_nodejs_project() {
321        let temp_dir = TempDir::new().expect("failed to create temp dir");
322        let project_dir = temp_dir.path().join("node-project");
323        std::fs::create_dir(&project_dir).expect("failed to create project dir");
324
325        // Create package.json
326        std::fs::write(project_dir.join("package.json"), "{}")
327            .expect("failed to write package.json");
328
329        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
330        let project = scanner.detect_project(&project_dir).await;
331
332        assert!(project.is_some());
333        let proj = project.unwrap();
334        assert_eq!(proj.name, "node-project");
335        assert_eq!(proj.project_type, "nodejs");
336    }
337
338    #[tokio::test]
339    async fn test_detect_python_project() {
340        let temp_dir = TempDir::new().expect("failed to create temp dir");
341        let project_dir = temp_dir.path().join("python-project");
342        std::fs::create_dir(&project_dir).expect("failed to create project dir");
343
344        // Create pyproject.toml
345        std::fs::write(project_dir.join("pyproject.toml"), "[build-system]")
346            .expect("failed to write pyproject.toml");
347
348        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
349        let project = scanner.detect_project(&project_dir).await;
350
351        assert!(project.is_some());
352        let proj = project.unwrap();
353        assert_eq!(proj.name, "python-project");
354        assert_eq!(proj.project_type, "python");
355    }
356
357    #[tokio::test]
358    async fn test_scan_workspace_with_projects() {
359        let temp_dir = TempDir::new().expect("failed to create temp dir");
360        let projects_dir = temp_dir.path().join("projects");
361        std::fs::create_dir(&projects_dir).expect("failed to create projects dir");
362
363        // Create a Rust project
364        let rust_project = projects_dir.join("rust-project");
365        std::fs::create_dir(&rust_project).expect("failed to create rust project");
366        std::fs::write(rust_project.join("Cargo.toml"), "[package]\nname = \"test\"")
367            .expect("failed to write Cargo.toml");
368
369        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
370        let projects = scanner.scan_workspace().await.expect("scan failed");
371
372        assert_eq!(projects.len(), 1);
373        assert_eq!(projects[0].name, "rust-project");
374        assert_eq!(projects[0].project_type, "rust");
375    }
376
377    #[tokio::test]
378    async fn test_extract_rust_version() {
379        let temp_dir = TempDir::new().expect("failed to create temp dir");
380        let cargo_toml = temp_dir.path().join("Cargo.toml");
381        std::fs::write(
382            &cargo_toml,
383            "[package]\nname = \"test\"\nversion = \"0.2.5\"\n",
384        )
385        .expect("failed to write Cargo.toml");
386
387        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
388        let version = scanner.extract_rust_version(&cargo_toml).await;
389
390        assert_eq!(version, "0.2.5");
391    }
392
393    #[tokio::test]
394    async fn test_extract_nodejs_version() {
395        let temp_dir = TempDir::new().expect("failed to create temp dir");
396        let package_json = temp_dir.path().join("package.json");
397        std::fs::write(
398            &package_json,
399            r#"{"name": "test", "version": "1.0.0"}"#,
400        )
401        .expect("failed to write package.json");
402
403        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
404        let version = scanner.extract_nodejs_version(&package_json).await;
405
406        assert_eq!(version, "1.0.0");
407    }
408
409    #[tokio::test]
410    async fn test_extract_python_version() {
411        let temp_dir = TempDir::new().expect("failed to create temp dir");
412        let pyproject_toml = temp_dir.path().join("pyproject.toml");
413        std::fs::write(
414            &pyproject_toml,
415            "[project]\nname = \"test\"\nversion = \"2.1.0\"\n",
416        )
417        .expect("failed to write pyproject.toml");
418
419        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
420        let version = scanner.extract_python_version(&pyproject_toml).await;
421
422        assert_eq!(version, "2.1.0");
423    }
424
425    #[tokio::test]
426    async fn test_extract_rust_version_with_quotes() {
427        let temp_dir = TempDir::new().expect("failed to create temp dir");
428        let cargo_toml = temp_dir.path().join("Cargo.toml");
429        std::fs::write(
430            &cargo_toml,
431            "[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
432        )
433        .expect("failed to write Cargo.toml");
434
435        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
436        let version = scanner.extract_rust_version(&cargo_toml).await;
437
438        assert_eq!(version, "0.3.0");
439    }
440
441    #[tokio::test]
442    async fn test_extract_version_from_malformed_file() {
443        let temp_dir = TempDir::new().expect("failed to create temp dir");
444        let cargo_toml = temp_dir.path().join("Cargo.toml");
445        std::fs::write(&cargo_toml, "[package]\nname = \"test\"\n")
446            .expect("failed to write Cargo.toml");
447
448        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
449        let version = scanner.extract_rust_version(&cargo_toml).await;
450
451        // Should return default version when version field is missing
452        assert_eq!(version, "0.1.0");
453    }
454
455    #[tokio::test]
456    async fn test_scan_workspace_with_multiple_projects() {
457        let temp_dir = TempDir::new().expect("failed to create temp dir");
458        let projects_dir = temp_dir.path().join("projects");
459        std::fs::create_dir(&projects_dir).expect("failed to create projects dir");
460
461        // Create multiple projects
462        for i in 0..3 {
463            let project_dir = projects_dir.join(format!("project-{}", i));
464            std::fs::create_dir(&project_dir).expect("failed to create project dir");
465            std::fs::write(
466                project_dir.join("Cargo.toml"),
467                format!("[package]\nname = \"project-{}\"\nversion = \"0.{}.0\"\n", i, i),
468            )
469            .expect("failed to write Cargo.toml");
470        }
471
472        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
473        let projects = scanner.scan_workspace().await.expect("scan failed");
474
475        assert_eq!(projects.len(), 3);
476        for (i, project) in projects.iter().enumerate() {
477            assert_eq!(project.name, format!("project-{}", i));
478            assert_eq!(project.project_type, "rust");
479        }
480    }
481
482    #[tokio::test]
483    async fn test_scan_workspace_with_mixed_projects() {
484        let temp_dir = TempDir::new().expect("failed to create temp dir");
485        let projects_dir = temp_dir.path().join("projects");
486        std::fs::create_dir(&projects_dir).expect("failed to create projects dir");
487
488        // Create a Rust project
489        let rust_project = projects_dir.join("rust-project");
490        std::fs::create_dir(&rust_project).expect("failed to create rust project");
491        std::fs::write(rust_project.join("Cargo.toml"), "[package]\nname = \"test\"")
492            .expect("failed to write Cargo.toml");
493
494        // Create a Node.js project
495        let node_project = projects_dir.join("node-project");
496        std::fs::create_dir(&node_project).expect("failed to create node project");
497        std::fs::write(node_project.join("package.json"), "{}")
498            .expect("failed to write package.json");
499
500        // Create a Python project
501        let python_project = projects_dir.join("python-project");
502        std::fs::create_dir(&python_project).expect("failed to create python project");
503        std::fs::write(python_project.join("pyproject.toml"), "[build-system]")
504            .expect("failed to write pyproject.toml");
505
506        let scanner = WorkspaceScanner::new(temp_dir.path().to_path_buf());
507        let projects = scanner.scan_workspace().await.expect("scan failed");
508
509        assert_eq!(projects.len(), 3);
510
511        let project_types: Vec<_> = projects.iter().map(|p| p.project_type.as_str()).collect();
512        assert!(project_types.contains(&"rust"));
513        assert!(project_types.contains(&"nodejs"));
514        assert!(project_types.contains(&"python"));
515    }
516}