ricecoder_orchestration/analyzers/
project_detector.rs

1//! Project detection and metadata extraction
2
3use crate::error::{OrchestrationError, Result};
4use crate::models::{Project, ProjectStatus};
5use std::path::PathBuf;
6use tracing::{debug, warn};
7
8/// Detects project type and extracts metadata from project manifests
9pub struct ProjectDetector;
10
11impl ProjectDetector {
12    /// Detects the type of a project from its manifest files
13    ///
14    /// # Arguments
15    ///
16    /// * `path` - The path to the project directory
17    ///
18    /// # Returns
19    ///
20    /// The project type as a string, or None if not detected
21    pub fn detect_project_type(path: &std::path::Path) -> Option<String> {
22        // Check for Cargo.toml (Rust project)
23        if path.join("Cargo.toml").exists() {
24            return Some("rust".to_string());
25        }
26
27        // Check for package.json (Node.js project)
28        if path.join("package.json").exists() {
29            return Some("nodejs".to_string());
30        }
31
32        // Check for pyproject.toml (Python project)
33        if path.join("pyproject.toml").exists() {
34            return Some("python".to_string());
35        }
36
37        // Check for go.mod (Go project)
38        if path.join("go.mod").exists() {
39            return Some("go".to_string());
40        }
41
42        // Check for pom.xml (Java/Maven project)
43        if path.join("pom.xml").exists() {
44            return Some("java".to_string());
45        }
46
47        // Check for build.gradle (Gradle project)
48        if path.join("build.gradle").exists() || path.join("build.gradle.kts").exists() {
49            return Some("gradle".to_string());
50        }
51
52        None
53    }
54
55    /// Extracts project metadata from a project directory
56    ///
57    /// # Arguments
58    ///
59    /// * `path` - The path to the project directory
60    ///
61    /// # Returns
62    ///
63    /// A Project struct with extracted metadata, or an error if extraction fails
64    pub fn extract_metadata(path: &PathBuf) -> Result<Project> {
65        let project_type = Self::detect_project_type(path)
66            .ok_or_else(|| OrchestrationError::ProjectNotFound(
67                format!("No project manifest found in {:?}", path),
68            ))?;
69
70        let name = path
71            .file_name()
72            .and_then(|n| n.to_str())
73            .map(|s| s.to_string())
74            .ok_or_else(|| OrchestrationError::ProjectNotFound(
75                format!("Invalid project path: {:?}", path),
76            ))?;
77
78        let version = Self::extract_version(path, &project_type);
79
80        Ok(Project {
81            path: path.clone(),
82            name,
83            project_type,
84            version,
85            status: ProjectStatus::Unknown,
86        })
87    }
88
89    /// Extracts version from project manifest
90    ///
91    /// # Arguments
92    ///
93    /// * `path` - The path to the project directory
94    /// * `project_type` - The type of project
95    ///
96    /// # Returns
97    ///
98    /// The version string, or "0.1.0" if extraction fails
99    fn extract_version(path: &std::path::Path, project_type: &str) -> String {
100        match project_type {
101            "rust" => Self::extract_rust_version(path),
102            "nodejs" => Self::extract_nodejs_version(path),
103            "python" => Self::extract_python_version(path),
104            "go" => Self::extract_go_version(path),
105            "java" => Self::extract_java_version(path),
106            "gradle" => Self::extract_gradle_version(path),
107            _ => "0.1.0".to_string(),
108        }
109    }
110
111    /// Extracts version from Cargo.toml
112    fn extract_rust_version(path: &std::path::Path) -> String {
113        let cargo_toml = path.join("Cargo.toml");
114        match std::fs::read_to_string(&cargo_toml) {
115            Ok(content) => {
116                for line in content.lines() {
117                    if line.contains("version") && line.contains("=") {
118                        if let Some(version_part) = line.split('=').nth(1) {
119                            let version = version_part
120                                .trim()
121                                .trim_matches('"')
122                                .trim_matches('\'')
123                                .to_string();
124                            if !version.is_empty() {
125                                debug!("Extracted Rust version: {}", version);
126                                return version;
127                            }
128                        }
129                    }
130                }
131                "0.1.0".to_string()
132            }
133            Err(e) => {
134                warn!("Failed to read Cargo.toml: {}", e);
135                "0.1.0".to_string()
136            }
137        }
138    }
139
140    /// Extracts version from package.json
141    fn extract_nodejs_version(path: &std::path::Path) -> String {
142        let package_json = path.join("package.json");
143        match std::fs::read_to_string(&package_json) {
144            Ok(content) => {
145                // Try JSON parsing first
146                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
147                    if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
148                        debug!("Extracted Node.js version: {}", version);
149                        return version.to_string();
150                    }
151                }
152                
153                // Fallback to line-by-line parsing
154                for line in content.lines() {
155                    if line.contains("\"version\"") && line.contains(":") {
156                        if let Some(version_part) = line.split(':').nth(1) {
157                            let version = version_part
158                                .trim()
159                                .trim_matches(',')
160                                .trim_matches('"')
161                                .to_string();
162                            if !version.is_empty() && !version.contains('{') && !version.contains('}') {
163                                debug!("Extracted Node.js version: {}", version);
164                                return version;
165                            }
166                        }
167                    }
168                }
169                "0.1.0".to_string()
170            }
171            Err(e) => {
172                warn!("Failed to read package.json: {}", e);
173                "0.1.0".to_string()
174            }
175        }
176    }
177
178    /// Extracts version from pyproject.toml
179    fn extract_python_version(path: &std::path::Path) -> String {
180        let pyproject_toml = path.join("pyproject.toml");
181        match std::fs::read_to_string(&pyproject_toml) {
182            Ok(content) => {
183                for line in content.lines() {
184                    if line.contains("version") && line.contains("=") {
185                        if let Some(version_part) = line.split('=').nth(1) {
186                            let version = version_part
187                                .trim()
188                                .trim_matches('"')
189                                .trim_matches('\'')
190                                .to_string();
191                            if !version.is_empty() {
192                                debug!("Extracted Python version: {}", version);
193                                return version;
194                            }
195                        }
196                    }
197                }
198                "0.1.0".to_string()
199            }
200            Err(e) => {
201                warn!("Failed to read pyproject.toml: {}", e);
202                "0.1.0".to_string()
203            }
204        }
205    }
206
207    /// Extracts version from go.mod
208    fn extract_go_version(path: &std::path::Path) -> String {
209        let go_mod = path.join("go.mod");
210        match std::fs::read_to_string(&go_mod) {
211            Ok(content) => {
212                // Go modules don't have versions in go.mod, use module name
213                for line in content.lines() {
214                    if line.starts_with("module ") {
215                        debug!("Found Go module: {}", line);
216                        return "0.1.0".to_string();
217                    }
218                }
219                "0.1.0".to_string()
220            }
221            Err(e) => {
222                warn!("Failed to read go.mod: {}", e);
223                "0.1.0".to_string()
224            }
225        }
226    }
227
228    /// Extracts version from pom.xml
229    fn extract_java_version(path: &std::path::Path) -> String {
230        let pom_xml = path.join("pom.xml");
231        match std::fs::read_to_string(&pom_xml) {
232            Ok(content) => {
233                for line in content.lines() {
234                    if line.contains("<version>") {
235                        if let Some(version_part) = line.split("<version>").nth(1) {
236                            if let Some(version) = version_part.split("</version>").next() {
237                                let version = version.trim().to_string();
238                                if !version.is_empty() {
239                                    debug!("Extracted Java version: {}", version);
240                                    return version;
241                                }
242                            }
243                        }
244                    }
245                }
246                "0.1.0".to_string()
247            }
248            Err(e) => {
249                warn!("Failed to read pom.xml: {}", e);
250                "0.1.0".to_string()
251            }
252        }
253    }
254
255    /// Extracts version from build.gradle or build.gradle.kts
256    fn extract_gradle_version(path: &std::path::Path) -> String {
257        let gradle_file = if path.join("build.gradle.kts").exists() {
258            path.join("build.gradle.kts")
259        } else {
260            path.join("build.gradle")
261        };
262
263        match std::fs::read_to_string(&gradle_file) {
264            Ok(content) => {
265                for line in content.lines() {
266                    if line.contains("version") && line.contains("=") {
267                        if let Some(version_part) = line.split('=').nth(1) {
268                            let version = version_part
269                                .trim()
270                                .trim_matches('"')
271                                .trim_matches('\'')
272                                .to_string();
273                            if !version.is_empty() {
274                                debug!("Extracted Gradle version: {}", version);
275                                return version;
276                            }
277                        }
278                    }
279                }
280                "0.1.0".to_string()
281            }
282            Err(e) => {
283                warn!("Failed to read build.gradle: {}", e);
284                "0.1.0".to_string()
285            }
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use tempfile::TempDir;
294
295    #[test]
296    fn test_detect_rust_project() {
297        let temp_dir = TempDir::new().expect("failed to create temp dir");
298        let project_dir = temp_dir.path().to_path_buf();
299        std::fs::write(project_dir.join("Cargo.toml"), "[package]")
300            .expect("failed to write Cargo.toml");
301
302        let project_type = ProjectDetector::detect_project_type(&project_dir);
303        assert_eq!(project_type, Some("rust".to_string()));
304    }
305
306    #[test]
307    fn test_detect_nodejs_project() {
308        let temp_dir = TempDir::new().expect("failed to create temp dir");
309        let project_dir = temp_dir.path().to_path_buf();
310        std::fs::write(project_dir.join("package.json"), "{}")
311            .expect("failed to write package.json");
312
313        let project_type = ProjectDetector::detect_project_type(&project_dir);
314        assert_eq!(project_type, Some("nodejs".to_string()));
315    }
316
317    #[test]
318    fn test_detect_python_project() {
319        let temp_dir = TempDir::new().expect("failed to create temp dir");
320        let project_dir = temp_dir.path().to_path_buf();
321        std::fs::write(project_dir.join("pyproject.toml"), "[build-system]")
322            .expect("failed to write pyproject.toml");
323
324        let project_type = ProjectDetector::detect_project_type(&project_dir);
325        assert_eq!(project_type, Some("python".to_string()));
326    }
327
328    #[test]
329    fn test_detect_go_project() {
330        let temp_dir = TempDir::new().expect("failed to create temp dir");
331        let project_dir = temp_dir.path().to_path_buf();
332        std::fs::write(project_dir.join("go.mod"), "module example.com/test")
333            .expect("failed to write go.mod");
334
335        let project_type = ProjectDetector::detect_project_type(&project_dir);
336        assert_eq!(project_type, Some("go".to_string()));
337    }
338
339    #[test]
340    fn test_detect_java_project() {
341        let temp_dir = TempDir::new().expect("failed to create temp dir");
342        let project_dir = temp_dir.path().to_path_buf();
343        std::fs::write(project_dir.join("pom.xml"), "<project>")
344            .expect("failed to write pom.xml");
345
346        let project_type = ProjectDetector::detect_project_type(&project_dir);
347        assert_eq!(project_type, Some("java".to_string()));
348    }
349
350    #[test]
351    fn test_detect_gradle_project() {
352        let temp_dir = TempDir::new().expect("failed to create temp dir");
353        let project_dir = temp_dir.path().to_path_buf();
354        std::fs::write(project_dir.join("build.gradle"), "plugins {}")
355            .expect("failed to write build.gradle");
356
357        let project_type = ProjectDetector::detect_project_type(&project_dir);
358        assert_eq!(project_type, Some("gradle".to_string()));
359    }
360
361    #[test]
362    fn test_detect_unknown_project() {
363        let temp_dir = TempDir::new().expect("failed to create temp dir");
364        let project_dir = temp_dir.path().to_path_buf();
365
366        let project_type = ProjectDetector::detect_project_type(&project_dir);
367        assert_eq!(project_type, None);
368    }
369
370    #[test]
371    fn test_extract_metadata_rust() {
372        let temp_dir = TempDir::new().expect("failed to create temp dir");
373        let project_dir = temp_dir.path().to_path_buf();
374        std::fs::write(
375            project_dir.join("Cargo.toml"),
376            "[package]\nname = \"test\"\nversion = \"0.2.0\"\n",
377        )
378        .expect("failed to write Cargo.toml");
379
380        let project = ProjectDetector::extract_metadata(&project_dir)
381            .expect("failed to extract metadata");
382
383        assert_eq!(project.project_type, "rust");
384        assert_eq!(project.version, "0.2.0");
385    }
386
387    #[test]
388    fn test_extract_metadata_nodejs() {
389        let temp_dir = TempDir::new().expect("failed to create temp dir");
390        let project_dir = temp_dir.path().to_path_buf();
391        std::fs::write(
392            project_dir.join("package.json"),
393            r#"{"name": "test", "version": "1.0.0"}"#,
394        )
395        .expect("failed to write package.json");
396
397        let project = ProjectDetector::extract_metadata(&project_dir)
398            .expect("failed to extract metadata");
399
400        assert_eq!(project.project_type, "nodejs");
401        assert_eq!(project.version, "1.0.0");
402    }
403
404    #[test]
405    fn test_extract_metadata_python() {
406        let temp_dir = TempDir::new().expect("failed to create temp dir");
407        let project_dir = temp_dir.path().to_path_buf();
408        std::fs::write(
409            project_dir.join("pyproject.toml"),
410            "[project]\nname = \"test\"\nversion = \"2.1.0\"\n",
411        )
412        .expect("failed to write pyproject.toml");
413
414        let project = ProjectDetector::extract_metadata(&project_dir)
415            .expect("failed to extract metadata");
416
417        assert_eq!(project.project_type, "python");
418        assert_eq!(project.version, "2.1.0");
419    }
420
421    #[test]
422    fn test_extract_metadata_missing_manifest() {
423        let temp_dir = TempDir::new().expect("failed to create temp dir");
424        let project_dir = temp_dir.path().to_path_buf();
425
426        let result = ProjectDetector::extract_metadata(&project_dir);
427        assert!(result.is_err());
428    }
429
430    #[test]
431    fn test_extract_rust_version_with_quotes() {
432        let temp_dir = TempDir::new().expect("failed to create temp dir");
433        let project_dir = temp_dir.path().to_path_buf();
434        std::fs::write(
435            project_dir.join("Cargo.toml"),
436            "[package]\nversion = \"0.3.0\"\n",
437        )
438        .expect("failed to write Cargo.toml");
439
440        let version = ProjectDetector::extract_rust_version(&project_dir);
441        assert_eq!(version, "0.3.0");
442    }
443
444    #[test]
445    fn test_extract_version_missing_field() {
446        let temp_dir = TempDir::new().expect("failed to create temp dir");
447        let project_dir = temp_dir.path().to_path_buf();
448        std::fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"\n")
449            .expect("failed to write Cargo.toml");
450
451        let version = ProjectDetector::extract_rust_version(&project_dir);
452        assert_eq!(version, "0.1.0");
453    }
454
455    #[test]
456    fn test_extract_java_version() {
457        let temp_dir = TempDir::new().expect("failed to create temp dir");
458        let project_dir = temp_dir.path().to_path_buf();
459        std::fs::write(
460            project_dir.join("pom.xml"),
461            "<project><version>1.2.3</version></project>",
462        )
463        .expect("failed to write pom.xml");
464
465        let version = ProjectDetector::extract_java_version(&project_dir);
466        assert_eq!(version, "1.2.3");
467    }
468
469    #[test]
470    fn test_extract_gradle_version() {
471        let temp_dir = TempDir::new().expect("failed to create temp dir");
472        let project_dir = temp_dir.path().to_path_buf();
473        std::fs::write(
474            project_dir.join("build.gradle"),
475            "plugins {}\nversion = \"3.0.0\"\n",
476        )
477        .expect("failed to write build.gradle");
478
479        let version = ProjectDetector::extract_gradle_version(&project_dir);
480        assert_eq!(version, "3.0.0");
481    }
482}