syncable_cli/analyzer/
framework_detector.rs

1use crate::analyzer::frameworks::*;
2use crate::analyzer::{AnalysisConfig, DetectedLanguage, DetectedTechnology};
3use crate::error::Result;
4use std::path::Path;
5
6/// Detects technologies (frameworks, libraries, tools) with proper classification
7pub fn detect_frameworks(
8    _project_root: &Path,
9    languages: &[DetectedLanguage],
10    _config: &AnalysisConfig,
11) -> Result<Vec<DetectedTechnology>> {
12    let mut all_technologies = Vec::new();
13
14    // Initialize language-specific detectors
15    let rust_detector = rust::RustFrameworkDetector;
16    let js_detector = javascript::JavaScriptFrameworkDetector;
17    let python_detector = python::PythonFrameworkDetector;
18    let go_detector = go::GoFrameworkDetector;
19    let java_detector = java::JavaFrameworkDetector;
20
21    for language in languages {
22        let lang_technologies = match language.name.as_str() {
23            "Rust" => rust_detector.detect_frameworks(language)?,
24            "JavaScript" | "TypeScript" | "JavaScript/TypeScript" => {
25                js_detector.detect_frameworks(language)?
26            }
27            "Python" => python_detector.detect_frameworks(language)?,
28            "Go" => go_detector.detect_frameworks(language)?,
29            "Java" | "Kotlin" | "Java/Kotlin" => java_detector.detect_frameworks(language)?,
30            _ => Vec::new(),
31        };
32        all_technologies.extend(lang_technologies);
33    }
34
35    // Apply exclusivity rules and resolve conflicts
36    let resolved_technologies =
37        FrameworkDetectionUtils::resolve_technology_conflicts(all_technologies);
38
39    // Mark primary technologies
40    let final_technologies =
41        FrameworkDetectionUtils::mark_primary_technologies(resolved_technologies);
42
43    // Sort by confidence and remove exact duplicates
44    let mut result = final_technologies;
45    result.sort_by(|a, b| {
46        b.confidence
47            .partial_cmp(&a.confidence)
48            .unwrap_or(std::cmp::Ordering::Equal)
49    });
50    result.dedup_by(|a, b| a.name == b.name);
51
52    Ok(result)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::analyzer::{LibraryType, TechnologyCategory};
59    use std::path::PathBuf;
60
61    #[test]
62    fn test_rust_actix_web_detection() {
63        let language = DetectedLanguage {
64            name: "Rust".to_string(),
65            version: Some("1.70.0".to_string()),
66            confidence: 0.9,
67            files: vec![PathBuf::from("src/main.rs")],
68            main_dependencies: vec!["actix-web".to_string(), "tokio".to_string()],
69            dev_dependencies: vec!["assert_cmd".to_string()],
70            package_manager: Some("cargo".to_string()),
71        };
72
73        let config = AnalysisConfig::default();
74        let project_root = Path::new(".");
75
76        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
77
78        // Should detect Actix Web and Tokio
79        let actix_web = technologies.iter().find(|t| t.name == "Actix Web");
80        let tokio = technologies.iter().find(|t| t.name == "Tokio");
81
82        if let Some(actix) = actix_web {
83            assert!(matches!(
84                actix.category,
85                TechnologyCategory::BackendFramework
86            ));
87            assert!(actix.is_primary);
88            assert!(actix.confidence > 0.8);
89        }
90
91        if let Some(tokio_tech) = tokio {
92            assert!(matches!(tokio_tech.category, TechnologyCategory::Runtime));
93            assert!(!tokio_tech.is_primary);
94        }
95    }
96
97    #[test]
98    fn test_javascript_next_js_detection() {
99        let language = DetectedLanguage {
100            name: "JavaScript".to_string(),
101            version: Some("18.0.0".to_string()),
102            confidence: 0.9,
103            files: vec![PathBuf::from("pages/index.js")],
104            main_dependencies: vec![
105                "next".to_string(),
106                "react".to_string(),
107                "react-dom".to_string(),
108            ],
109            dev_dependencies: vec!["eslint".to_string()],
110            package_manager: Some("npm".to_string()),
111        };
112
113        let config = AnalysisConfig::default();
114        let project_root = Path::new(".");
115
116        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
117
118        // Should detect Next.js and React
119        let nextjs = technologies.iter().find(|t| t.name == "Next.js");
120        let react = technologies.iter().find(|t| t.name == "React");
121
122        if let Some(next) = nextjs {
123            assert!(matches!(next.category, TechnologyCategory::MetaFramework));
124            assert!(next.is_primary);
125            assert!(next.requires.contains(&"React".to_string()));
126        }
127
128        if let Some(react_tech) = react {
129            assert!(matches!(
130                react_tech.category,
131                TechnologyCategory::Library(LibraryType::UI)
132            ));
133            assert!(!react_tech.is_primary); // Should be false since Next.js is the meta-framework
134        }
135    }
136
137    #[test]
138    fn test_vite_react_is_not_misclassified_as_next() {
139        let language = DetectedLanguage {
140            name: "TypeScript".to_string(),
141            version: Some("18.0.0".to_string()),
142            confidence: 0.9,
143            files: vec![PathBuf::from("src/App.tsx")],
144            main_dependencies: vec![
145                "react".to_string(),
146                "react-dom".to_string(),
147                "vite".to_string(),
148            ],
149            dev_dependencies: vec!["vite".to_string()],
150            package_manager: Some("npm".to_string()),
151        };
152
153        let config = AnalysisConfig::default();
154        let project_root = Path::new(".");
155
156        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
157
158        assert!(technologies.iter().any(|t| t.name == "Vite"));
159        assert!(technologies.iter().any(|t| t.name == "React"));
160        assert!(technologies.iter().all(|t| t.name != "Next.js"));
161    }
162
163    #[test]
164    fn test_tanstack_start_detection_over_structure_only() {
165        let language = DetectedLanguage {
166            name: "TypeScript".to_string(),
167            version: Some("18.0.0".to_string()),
168            confidence: 0.9,
169            files: vec![PathBuf::from("app/routes/index.tsx")],
170            main_dependencies: vec![
171                "@tanstack/react-start".to_string(),
172                "@tanstack/react-router".to_string(),
173                "react".to_string(),
174                "react-dom".to_string(),
175            ],
176            dev_dependencies: vec![],
177            package_manager: Some("npm".to_string()),
178        };
179
180        let config = AnalysisConfig::default();
181        let project_root = Path::new(".");
182
183        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
184
185        assert!(technologies.iter().any(|t| t.name == "Tanstack Start"));
186        assert!(technologies.iter().all(|t| t.name != "Next.js"));
187    }
188
189    #[test]
190    fn test_python_fastapi_detection() {
191        let language = DetectedLanguage {
192            name: "Python".to_string(),
193            version: Some("3.11.0".to_string()),
194            confidence: 0.95,
195            files: vec![PathBuf::from("main.py")],
196            main_dependencies: vec![
197                "fastapi".to_string(),
198                "uvicorn".to_string(),
199                "pydantic".to_string(),
200            ],
201            dev_dependencies: vec!["pytest".to_string()],
202            package_manager: Some("pip".to_string()),
203        };
204
205        let config = AnalysisConfig::default();
206        let project_root = Path::new(".");
207
208        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
209
210        // Should detect FastAPI and Uvicorn
211        let fastapi = technologies.iter().find(|t| t.name == "FastAPI");
212        let uvicorn = technologies.iter().find(|t| t.name == "Uvicorn");
213
214        if let Some(fastapi_tech) = fastapi {
215            assert!(matches!(
216                fastapi_tech.category,
217                TechnologyCategory::BackendFramework
218            ));
219            assert!(fastapi_tech.is_primary);
220        }
221
222        if let Some(uvicorn_tech) = uvicorn {
223            assert!(matches!(uvicorn_tech.category, TechnologyCategory::Runtime));
224            assert!(!uvicorn_tech.is_primary);
225        }
226    }
227
228    #[test]
229    fn test_go_gin_detection() {
230        let language = DetectedLanguage {
231            name: "Go".to_string(),
232            version: Some("1.21.0".to_string()),
233            confidence: 0.95,
234            files: vec![PathBuf::from("main.go")],
235            main_dependencies: vec![
236                "github.com/gin-gonic/gin".to_string(),
237                "gorm.io/gorm".to_string(),
238            ],
239            dev_dependencies: vec!["github.com/stretchr/testify".to_string()],
240            package_manager: Some("go mod".to_string()),
241        };
242
243        let config = AnalysisConfig::default();
244        let project_root = Path::new(".");
245
246        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
247
248        // Should detect Gin and GORM
249        let gin = technologies.iter().find(|t| t.name == "Gin");
250        let gorm = technologies.iter().find(|t| t.name == "GORM");
251
252        if let Some(gin_tech) = gin {
253            assert!(matches!(
254                gin_tech.category,
255                TechnologyCategory::BackendFramework
256            ));
257            assert!(gin_tech.is_primary);
258        }
259
260        if let Some(gorm_tech) = gorm {
261            assert!(matches!(gorm_tech.category, TechnologyCategory::Database));
262            assert!(!gorm_tech.is_primary);
263        }
264    }
265
266    #[test]
267    fn test_java_spring_boot_detection() {
268        let language = DetectedLanguage {
269            name: "Java".to_string(),
270            version: Some("17.0.0".to_string()),
271            confidence: 0.95,
272            files: vec![PathBuf::from("src/main/java/Application.java")],
273            main_dependencies: vec!["spring-boot".to_string(), "spring-web".to_string()],
274            dev_dependencies: vec!["junit".to_string()],
275            package_manager: Some("maven".to_string()),
276        };
277
278        let config = AnalysisConfig::default();
279        let project_root = Path::new(".");
280
281        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
282
283        // Should detect Spring Boot
284        let spring_boot = technologies.iter().find(|t| t.name == "Spring Boot");
285
286        if let Some(spring) = spring_boot {
287            assert!(matches!(
288                spring.category,
289                TechnologyCategory::BackendFramework
290            ));
291            assert!(spring.is_primary);
292        }
293    }
294
295    #[test]
296    fn test_technology_conflicts_resolution() {
297        let language = DetectedLanguage {
298            name: "Rust".to_string(),
299            version: Some("1.70.0".to_string()),
300            confidence: 0.95,
301            files: vec![PathBuf::from("src/main.rs")],
302            main_dependencies: vec![
303                "tokio".to_string(),
304                "async-std".to_string(), // These should conflict
305            ],
306            dev_dependencies: vec![],
307            package_manager: Some("cargo".to_string()),
308        };
309
310        let config = AnalysisConfig::default();
311        let project_root = Path::new(".");
312
313        let technologies = detect_frameworks(project_root, &[language], &config).unwrap();
314
315        // Should only have one async runtime (higher confidence wins)
316        let async_runtimes: Vec<_> = technologies
317            .iter()
318            .filter(|t| matches!(t.category, TechnologyCategory::Runtime))
319            .collect();
320
321        assert!(
322            async_runtimes.len() <= 1,
323            "Should resolve conflicting async runtimes: found {:?}",
324            async_runtimes.iter().map(|t| &t.name).collect::<Vec<_>>()
325        );
326    }
327}