From cfcad9a99384e4ee66a3571527f0dab06b3be71c Mon Sep 17 00:00:00 2001 From: Martin Dandanell Date: Fri, 17 Oct 2025 10:33:44 +0200 Subject: [PATCH] feat: add support for NodeJS Azure Functions project type --- src/analyzer/context/project_type.rs | 113 ++++++++++++---- src/analyzer/framework_detector.rs | 171 ++++++++++++++++-------- src/analyzer/frameworks/javascript.rs | 27 +++- src/analyzer/mod.rs | 1 + src/analyzer/monorepo/project_info.rs | 94 +++++++++---- tests/javascript_framework_detection.rs | 166 ++++++++++++++++------- 6 files changed, 408 insertions(+), 164 deletions(-) diff --git a/src/analyzer/context/project_type.rs b/src/analyzer/context/project_type.rs index 1da3cc1b..721a74e4 100644 --- a/src/analyzer/context/project_type.rs +++ b/src/analyzer/context/project_type.rs @@ -1,5 +1,5 @@ -use crate::analyzer::{DetectedLanguage, DetectedTechnology, EntryPoint, Port, ProjectType}; use super::microservices::MicroserviceInfo; +use crate::analyzer::{DetectedLanguage, DetectedTechnology, EntryPoint, Port, ProjectType}; /// Enhanced project type determination including microservice structure analysis pub(crate) fn determine_project_type_with_structure( @@ -30,52 +30,107 @@ fn determine_project_type( let has_database_ports = ports.iter().any(|p| { if let Some(desc) = &p.description { let desc_lower = desc.to_lowercase(); - desc_lower.contains("postgres") || desc_lower.contains("mysql") || - desc_lower.contains("mongodb") || desc_lower.contains("database") + desc_lower.contains("postgres") + || desc_lower.contains("mysql") + || desc_lower.contains("mongodb") + || desc_lower.contains("database") } else { false } }); - let has_multiple_services = ports.iter() + let has_multiple_services = ports + .iter() .filter_map(|p| p.description.as_ref()) .filter(|desc| { let desc_lower = desc.to_lowercase(); desc_lower.contains("service") || desc_lower.contains("application") }) - .count() > 1; + .count() + > 1; - let has_orchestration_framework = technologies.iter() + let has_orchestration_framework = technologies + .iter() .any(|t| t.name == "Encore" || t.name == "Dapr" || t.name == "Temporal"); // Check for web frameworks - let web_frameworks = ["Express", "Fastify", "Koa", "Next.js", "React", "Vue", "Angular", - "Django", "Flask", "FastAPI", "Spring Boot", "Actix Web", "Rocket", - "Gin", "Echo", "Fiber", "Svelte", "SvelteKit", "SolidJS", "Astro", - "Encore", "Hono", "Elysia", "React Router v7", "Tanstack Start", - "SolidStart", "Qwik", "Nuxt.js", "Gatsby"]; + let web_frameworks = [ + "Express", + "Fastify", + "Koa", + "Next.js", + "React", + "Vue", + "Angular", + "Django", + "Flask", + "FastAPI", + "Spring Boot", + "Actix Web", + "Rocket", + "Gin", + "Echo", + "Fiber", + "Svelte", + "SvelteKit", + "SolidJS", + "Astro", + "Encore", + "Hono", + "Elysia", + "React Router v7", + "Tanstack Start", + "SolidStart", + "Qwik", + "Nuxt.js", + "Gatsby", + ]; - let has_web_framework = technologies.iter() + let has_web_framework = technologies + .iter() .any(|t| web_frameworks.contains(&t.name.as_str())); // Check for CLI indicators let cli_indicators = ["cobra", "clap", "argparse", "commander"]; - let has_cli_framework = technologies.iter() + let has_cli_framework = technologies + .iter() .any(|t| cli_indicators.contains(&t.name.to_lowercase().as_str())); // Check for API indicators - let api_frameworks = ["FastAPI", "Express", "Gin", "Echo", "Actix Web", "Spring Boot", - "Fastify", "Koa", "Nest.js", "Encore", "Hono", "Elysia"]; - let has_api_framework = technologies.iter() + let api_frameworks = [ + "FastAPI", + "Express", + "Gin", + "Echo", + "Actix Web", + "Spring Boot", + "Fastify", + "Koa", + "Nest.js", + "Encore", + "Hono", + "Elysia", + ]; + let has_api_framework = technologies + .iter() .any(|t| api_frameworks.contains(&t.name.as_str())); // Check for static site generators let static_generators = ["Gatsby", "Hugo", "Jekyll", "Eleventy", "Astro"]; - let has_static_generator = technologies.iter() + let has_static_generator = technologies + .iter() .any(|t| static_generators.contains(&t.name.as_str())); + // Check for functions app indicators + let functions_apps: [&'static str; 1] = ["Azure Functions"]; + let has_functions_app = technologies + .iter() + .any(|t| functions_apps.contains(&t.name.as_str())); + // Determine type based on indicators - if (has_database_ports || has_multiple_services) && (has_orchestration_framework || has_api_framework) { + if (has_database_ports || has_multiple_services) + && (has_orchestration_framework || has_api_framework) + { ProjectType::Microservice } else if has_static_generator { ProjectType::StaticSite @@ -83,17 +138,23 @@ fn determine_project_type( ProjectType::ApiService } else if has_web_framework { ProjectType::WebApplication + } else if has_functions_app { + ProjectType::FunctionApp } else if has_cli_framework || (entry_points.len() == 1 && ports.is_empty()) { ProjectType::CliTool } else if entry_points.is_empty() && ports.is_empty() { // Check if it's a library - let has_lib_indicators = languages.iter().any(|l| { - match l.name.as_str() { - "Rust" => l.files.iter().any(|f| f.to_string_lossy().contains("lib.rs")), - "Python" => l.files.iter().any(|f| f.to_string_lossy().contains("__init__.py")), - "JavaScript" | "TypeScript" => l.main_dependencies.is_empty(), - _ => false, - } + let has_lib_indicators = languages.iter().any(|l| match l.name.as_str() { + "Rust" => l + .files + .iter() + .any(|f| f.to_string_lossy().contains("lib.rs")), + "Python" => l + .files + .iter() + .any(|f| f.to_string_lossy().contains("__init__.py")), + "JavaScript" | "TypeScript" => l.main_dependencies.is_empty(), + _ => false, }); if has_lib_indicators { @@ -104,4 +165,4 @@ fn determine_project_type( } else { ProjectType::Unknown } -} \ No newline at end of file +} diff --git a/src/analyzer/framework_detector.rs b/src/analyzer/framework_detector.rs index 32193f14..76459c11 100644 --- a/src/analyzer/framework_detector.rs +++ b/src/analyzer/framework_detector.rs @@ -1,5 +1,5 @@ -use crate::analyzer::{AnalysisConfig, DetectedTechnology, DetectedLanguage}; use crate::analyzer::frameworks::*; +use crate::analyzer::{AnalysisConfig, DetectedLanguage, DetectedTechnology}; use crate::error::Result; use std::path::Path; @@ -10,18 +10,20 @@ pub fn detect_frameworks( _config: &AnalysisConfig, ) -> Result> { let mut all_technologies = Vec::new(); - + // Initialize language-specific detectors let rust_detector = rust::RustFrameworkDetector; let js_detector = javascript::JavaScriptFrameworkDetector; let python_detector = python::PythonFrameworkDetector; let go_detector = go::GoFrameworkDetector; let java_detector = java::JavaFrameworkDetector; - + for language in languages { let lang_technologies = match language.name.as_str() { "Rust" => rust_detector.detect_frameworks(language)?, - "JavaScript" | "TypeScript" | "JavaScript/TypeScript" => js_detector.detect_frameworks(language)?, + "JavaScript" | "TypeScript" | "JavaScript/TypeScript" => { + js_detector.detect_frameworks(language)? + } "Python" => python_detector.detect_frameworks(language)?, "Go" => go_detector.detect_frameworks(language)?, "Java" | "Kotlin" | "Java/Kotlin" => java_detector.detect_frameworks(language)?, @@ -29,27 +31,33 @@ pub fn detect_frameworks( }; all_technologies.extend(lang_technologies); } - + // Apply exclusivity rules and resolve conflicts - let resolved_technologies = FrameworkDetectionUtils::resolve_technology_conflicts(all_technologies); - + let resolved_technologies = + FrameworkDetectionUtils::resolve_technology_conflicts(all_technologies); + // Mark primary technologies - let final_technologies = FrameworkDetectionUtils::mark_primary_technologies(resolved_technologies); - + let final_technologies = + FrameworkDetectionUtils::mark_primary_technologies(resolved_technologies); + // Sort by confidence and remove exact duplicates let mut result = final_technologies; - result.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal)); + result.sort_by(|a, b| { + b.confidence + .partial_cmp(&a.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + }); result.dedup_by(|a, b| a.name == b.name); - + Ok(result) } #[cfg(test)] mod tests { use super::*; - use crate::analyzer::{TechnologyCategory, LibraryType}; + use crate::analyzer::{LibraryType, TechnologyCategory}; use std::path::PathBuf; - + #[test] fn test_rust_actix_web_detection() { let language = DetectedLanguage { @@ -61,28 +69,31 @@ mod tests { dev_dependencies: vec!["assert_cmd".to_string()], package_manager: Some("cargo".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Actix Web and Tokio let actix_web = technologies.iter().find(|t| t.name == "Actix Web"); let tokio = technologies.iter().find(|t| t.name == "Tokio"); - + if let Some(actix) = actix_web { - assert!(matches!(actix.category, TechnologyCategory::BackendFramework)); + assert!(matches!( + actix.category, + TechnologyCategory::BackendFramework + )); assert!(actix.is_primary); assert!(actix.confidence > 0.8); } - + if let Some(tokio_tech) = tokio { assert!(matches!(tokio_tech.category, TechnologyCategory::Runtime)); assert!(!tokio_tech.is_primary); } } - + #[test] fn test_javascript_next_js_detection() { let language = DetectedLanguage { @@ -98,28 +109,31 @@ mod tests { dev_dependencies: vec!["eslint".to_string()], package_manager: Some("npm".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Next.js and React let nextjs = technologies.iter().find(|t| t.name == "Next.js"); let react = technologies.iter().find(|t| t.name == "React"); - + if let Some(next) = nextjs { assert!(matches!(next.category, TechnologyCategory::MetaFramework)); assert!(next.is_primary); assert!(next.requires.contains(&"React".to_string())); } - + if let Some(react_tech) = react { - assert!(matches!(react_tech.category, TechnologyCategory::Library(LibraryType::UI))); + assert!(matches!( + react_tech.category, + TechnologyCategory::Library(LibraryType::UI) + )); assert!(!react_tech.is_primary); // Should be false since Next.js is the meta-framework } } - + #[test] fn test_python_fastapi_detection() { let language = DetectedLanguage { @@ -135,27 +149,30 @@ mod tests { dev_dependencies: vec!["pytest".to_string()], package_manager: Some("pip".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect FastAPI and Uvicorn let fastapi = technologies.iter().find(|t| t.name == "FastAPI"); let uvicorn = technologies.iter().find(|t| t.name == "Uvicorn"); - + if let Some(fastapi_tech) = fastapi { - assert!(matches!(fastapi_tech.category, TechnologyCategory::BackendFramework)); + assert!(matches!( + fastapi_tech.category, + TechnologyCategory::BackendFramework + )); assert!(fastapi_tech.is_primary); } - + if let Some(uvicorn_tech) = uvicorn { assert!(matches!(uvicorn_tech.category, TechnologyCategory::Runtime)); assert!(!uvicorn_tech.is_primary); } } - + #[test] fn test_go_gin_detection() { let language = DetectedLanguage { @@ -170,27 +187,30 @@ mod tests { dev_dependencies: vec!["github.com/stretchr/testify".to_string()], package_manager: Some("go mod".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Gin and GORM let gin = technologies.iter().find(|t| t.name == "Gin"); let gorm = technologies.iter().find(|t| t.name == "GORM"); - + if let Some(gin_tech) = gin { - assert!(matches!(gin_tech.category, TechnologyCategory::BackendFramework)); + assert!(matches!( + gin_tech.category, + TechnologyCategory::BackendFramework + )); assert!(gin_tech.is_primary); } - + if let Some(gorm_tech) = gorm { assert!(matches!(gorm_tech.category, TechnologyCategory::Database)); assert!(!gorm_tech.is_primary); } } - + #[test] fn test_java_spring_boot_detection() { let language = DetectedLanguage { @@ -198,28 +218,59 @@ mod tests { version: Some("17.0.0".to_string()), confidence: 0.95, files: vec![PathBuf::from("src/main/java/Application.java")], - main_dependencies: vec![ - "spring-boot".to_string(), - "spring-web".to_string(), - ], + main_dependencies: vec!["spring-boot".to_string(), "spring-web".to_string()], dev_dependencies: vec!["junit".to_string()], package_manager: Some("maven".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Spring Boot let spring_boot = technologies.iter().find(|t| t.name == "Spring Boot"); - + if let Some(spring) = spring_boot { - assert!(matches!(spring.category, TechnologyCategory::BackendFramework)); + assert!(matches!( + spring.category, + TechnologyCategory::BackendFramework + )); assert!(spring.is_primary); } } + #[test] + fn test_node_azure_functions_detection() { + let language = DetectedLanguage { + name: "JavaScript".to_string(), + version: Some("18.0.0".to_string()), + confidence: 0.95, + files: vec![ + PathBuf::from("api/HttpTrigger1/index.ts"), + PathBuf::from("host.json"), + PathBuf::from("api/HttpTrigger1/function.json"), + ], + main_dependencies: vec!["@azure/functions".to_string()], + dev_dependencies: vec![], + package_manager: Some("npm".to_string()), + }; + + let config = AnalysisConfig::default(); + let project_root = Path::new("."); + + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); + + // Should detect Azure Functions as a primary backend framework + let azure_functions = technologies.iter().find(|t| t.name == "Azure Functions"); + + if let Some(af) = azure_functions { + assert!(matches!(af.category, TechnologyCategory::BackendFramework)); + assert!(af.is_primary); + assert!(af.confidence >= 0.95); + } + } + #[test] fn test_technology_conflicts_resolution() { let language = DetectedLanguage { @@ -234,18 +285,22 @@ mod tests { dev_dependencies: vec![], package_manager: Some("cargo".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should only have one async runtime (higher confidence wins) - let async_runtimes: Vec<_> = technologies.iter() + let async_runtimes: Vec<_> = technologies + .iter() .filter(|t| matches!(t.category, TechnologyCategory::Runtime)) .collect(); - - assert!(async_runtimes.len() <= 1, "Should resolve conflicting async runtimes: found {:?}", - async_runtimes.iter().map(|t| &t.name).collect::>()); + + assert!( + async_runtimes.len() <= 1, + "Should resolve conflicting async runtimes: found {:?}", + async_runtimes.iter().map(|t| &t.name).collect::>() + ); } -} \ No newline at end of file +} diff --git a/src/analyzer/frameworks/javascript.rs b/src/analyzer/frameworks/javascript.rs index 8d3d5f2d..036383aa 100644 --- a/src/analyzer/frameworks/javascript.rs +++ b/src/analyzer/frameworks/javascript.rs @@ -229,15 +229,15 @@ fn detect_by_project_structure(language: &DetectedLanguage, rules: &[TechnologyR let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); // Check for React Native structure - if path_str.contains("android") { + if path_str.ends_with("/android") || path_str.contains("/android/") { has_android_dir = true; - } else if path_str.contains("ios") { + } else if path_str.ends_with("/ios") || path_str.contains("/ios/") { has_ios_dir = true; } // Check for Next.js structure - else if path_str.contains("pages") { + else if path_str.ends_with("/pages") || path_str.contains("/pages/") { has_pages_dir = true; - } else if path_str.contains("app") && !path_str.contains("app.config") && !path_str.contains("encore.app") { + } else if (path_str.ends_with("/app") || path_str.contains("/app/")) && !path_str.contains("app.config") && !path_str.contains("encore.app") { has_app_dir = true; } // Check for TanStack Start structure @@ -1003,6 +1003,25 @@ fn get_js_technology_rules() -> Vec { alternative_names: vec!["encore-ts-starter".to_string()], file_indicators: vec!["encore.app".to_string(), "encore.service.ts".to_string(), "encore.service.js".to_string()], }, + TechnologyRule { + name: "Azure Functions".to_string(), + category: TechnologyCategory::BackendFramework, + confidence: 0.95, + dependency_patterns: vec!["@azure/functions".to_string()], + requires: vec!["Node.js".to_string()], + conflicts_with: vec![ + // Similar backend frameworks + "Express.js".to_string(), + "Fastify".to_string(), + "Nest.js".to_string(), + "Hono".to_string(), + "Elysia".to_string(), + "Encore".to_string(), + ], + is_primary_indicator: true, + alternative_names: vec!["azure-functions".to_string()], + file_indicators: vec!["host.json".to_string(), "local.settings.json".to_string(), "function.json".to_string()], + }, // BUILD TOOLS (Not frameworks!) TechnologyRule { diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index c0590348..56f96e07 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -198,6 +198,7 @@ pub enum ProjectType { Library, MobileApp, DesktopApp, + FunctionApp, Microservice, StaticSite, Hybrid, // Multiple types diff --git a/src/analyzer/monorepo/project_info.rs b/src/analyzer/monorepo/project_info.rs index 7350bd2c..4079f4e1 100644 --- a/src/analyzer/monorepo/project_info.rs +++ b/src/analyzer/monorepo/project_info.rs @@ -21,9 +21,11 @@ pub(crate) fn extract_project_name(project_path: &Path, _analysis: &ProjectAnaly if cargo_toml_path.exists() { if let Ok(content) = std::fs::read_to_string(&cargo_toml_path) { if let Ok(cargo_toml) = toml::from_str::(&content) { - if let Some(name) = cargo_toml.get("package") + if let Some(name) = cargo_toml + .get("package") .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) { + .and_then(|n| n.as_str()) + { return name.to_string(); } } @@ -35,14 +37,18 @@ pub(crate) fn extract_project_name(project_path: &Path, _analysis: &ProjectAnaly if pyproject_toml_path.exists() { if let Ok(content) = std::fs::read_to_string(&pyproject_toml_path) { if let Ok(pyproject) = toml::from_str::(&content) { - if let Some(name) = pyproject.get("project") + if let Some(name) = pyproject + .get("project") .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) { + .and_then(|n| n.as_str()) + { return name.to_string(); - } else if let Some(name) = pyproject.get("tool") + } else if let Some(name) = pyproject + .get("tool") .and_then(|t| t.get("poetry")) .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) { + .and_then(|n| n.as_str()) + { return name.to_string(); } } @@ -50,29 +56,42 @@ pub(crate) fn extract_project_name(project_path: &Path, _analysis: &ProjectAnaly } // Fall back to directory name - project_path.file_name() + project_path + .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string() } /// Determines the category of a project based on its analysis -pub(crate) fn determine_project_category(analysis: &ProjectAnalysis, project_path: &Path) -> ProjectCategory { - let dir_name = project_path.file_name() +pub(crate) fn determine_project_category( + analysis: &ProjectAnalysis, + project_path: &Path, +) -> ProjectCategory { + let dir_name = project_path + .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_lowercase(); // Check directory name patterns first let category_from_name = match dir_name.as_str() { - name if name.contains("frontend") || name.contains("client") || name.contains("web") => Some(ProjectCategory::Frontend), - name if name.contains("backend") || name.contains("server") => Some(ProjectCategory::Backend), + name if name.contains("frontend") || name.contains("client") || name.contains("web") => { + Some(ProjectCategory::Frontend) + } + name if name.contains("backend") || name.contains("server") => { + Some(ProjectCategory::Backend) + } name if name.contains("api") => Some(ProjectCategory::Api), name if name.contains("service") => Some(ProjectCategory::Service), name if name.contains("lib") || name.contains("library") => Some(ProjectCategory::Library), name if name.contains("tool") || name.contains("cli") => Some(ProjectCategory::Tool), - name if name.contains("docs") || name.contains("doc") => Some(ProjectCategory::Documentation), - name if name.contains("infra") || name.contains("deploy") => Some(ProjectCategory::Infrastructure), + name if name.contains("docs") || name.contains("doc") => { + Some(ProjectCategory::Documentation) + } + name if name.contains("infra") || name.contains("deploy") => { + Some(ProjectCategory::Infrastructure) + } _ => None, }; @@ -83,28 +102,50 @@ pub(crate) fn determine_project_category(analysis: &ProjectAnalysis, project_pat // Analyze technologies to determine category let has_frontend_tech = analysis.technologies.iter().any(|t| { - matches!(t.name.as_str(), - "React" | "Vue.js" | "Angular" | "Next.js" | "Nuxt.js" | "Svelte" | - "Astro" | "Gatsby" | "Vite" | "Webpack" | "Parcel" + matches!( + t.name.as_str(), + "React" + | "Vue.js" + | "Angular" + | "Next.js" + | "Nuxt.js" + | "Svelte" + | "Astro" + | "Gatsby" + | "Vite" + | "Webpack" + | "Parcel" ) }); let has_backend_tech = analysis.technologies.iter().any(|t| { - matches!(t.name.as_str(), - "Express.js" | "FastAPI" | "Django" | "Flask" | "Actix Web" | "Rocket" | - "Spring Boot" | "Gin" | "Echo" | "Fiber" | "ASP.NET" + matches!( + t.name.as_str(), + "Express.js" + | "FastAPI" + | "Django" + | "Flask" + | "Actix Web" + | "Rocket" + | "Spring Boot" + | "Gin" + | "Echo" + | "Fiber" + | "ASP.NET" ) }); let has_api_tech = analysis.technologies.iter().any(|t| { - matches!(t.name.as_str(), + matches!( + t.name.as_str(), "REST API" | "GraphQL" | "gRPC" | "FastAPI" | "Express.js" ) }); - let has_database = analysis.technologies.iter().any(|t| { - matches!(t.category, crate::analyzer::TechnologyCategory::Database) - }); + let has_database = analysis + .technologies + .iter() + .any(|t| matches!(t.category, crate::analyzer::TechnologyCategory::Database)); if has_frontend_tech && !has_backend_tech { ProjectCategory::Frontend @@ -116,7 +157,12 @@ pub(crate) fn determine_project_category(analysis: &ProjectAnalysis, project_pat ProjectCategory::Library } else if matches!(analysis.project_type, crate::analyzer::ProjectType::CliTool) { ProjectCategory::Tool + } else if matches!( + analysis.project_type, + crate::analyzer::ProjectType::FunctionApp + ) { + ProjectCategory::Service } else { ProjectCategory::Unknown } -} \ No newline at end of file +} diff --git a/tests/javascript_framework_detection.rs b/tests/javascript_framework_detection.rs index 56d91841..8f08f958 100644 --- a/tests/javascript_framework_detection.rs +++ b/tests/javascript_framework_detection.rs @@ -1,8 +1,7 @@ +use std::path::Path; use syncable_cli::analyzer::{ - framework_detector::detect_frameworks, - AnalysisConfig, DetectedLanguage, TechnologyCategory + AnalysisConfig, DetectedLanguage, TechnologyCategory, framework_detector::detect_frameworks, }; -use std::path::Path; #[test] fn test_javascript_framework_detection_with_file_indicators() { @@ -31,14 +30,14 @@ fn test_javascript_framework_detection_with_file_indicators() { // Should detect Next.js with high confidence due to config file let nextjs = technologies.iter().find(|t| t.name == "Next.js"); - + assert!(nextjs.is_some(), "Next.js should be detected"); let nextjs = nextjs.unwrap(); - + assert!(matches!(nextjs.category, TechnologyCategory::MetaFramework)); assert!(nextjs.is_primary); assert!(nextjs.confidence > 0.9); // High confidence from config file detection - + // Should also detect React as a dependency let react = technologies.iter().find(|t| t.name == "React"); assert!(react.is_some(), "React should be detected"); @@ -71,10 +70,10 @@ fn test_expo_detection_with_config_file() { // Should detect Expo with high confidence due to config file let expo = technologies.iter().find(|t| t.name == "Expo"); - + assert!(expo.is_some(), "Expo should be detected"); let expo = expo.unwrap(); - + assert!(matches!(expo.category, TechnologyCategory::MetaFramework)); assert!(expo.is_primary); assert!(expo.confidence > 0.9); // High confidence from config file detection @@ -87,9 +86,7 @@ fn test_tanstack_start_detection_with_config_file() { name: "JavaScript".to_string(), version: Some("18.0.0".to_string()), confidence: 0.9, - files: vec![ - std::path::PathBuf::from("app.config.ts"), - ], + files: vec![std::path::PathBuf::from("app.config.ts")], main_dependencies: vec![ "@tanstack/react-start".to_string(), "react".to_string(), @@ -106,11 +103,14 @@ fn test_tanstack_start_detection_with_config_file() { // Should detect TanStack Start with high confidence let tanstack = technologies.iter().find(|t| t.name == "Tanstack Start"); - + assert!(tanstack.is_some(), "Tanstack Start should be detected"); let tanstack = tanstack.unwrap(); - - assert!(matches!(tanstack.category, TechnologyCategory::MetaFramework)); + + assert!(matches!( + tanstack.category, + TechnologyCategory::MetaFramework + )); assert!(tanstack.is_primary); assert!(tanstack.confidence > 0.9); // High confidence from dependency + config file } @@ -122,13 +122,8 @@ fn test_react_native_detection_with_config_file() { name: "JavaScript".to_string(), version: Some("18.0.0".to_string()), confidence: 0.9, - files: vec![ - std::path::PathBuf::from("react-native.config.js"), - ], - main_dependencies: vec![ - "react-native".to_string(), - "react".to_string(), - ], + files: vec![std::path::PathBuf::from("react-native.config.js")], + main_dependencies: vec!["react-native".to_string(), "react".to_string()], dev_dependencies: vec![], package_manager: Some("npm".to_string()), }; @@ -140,11 +135,14 @@ fn test_react_native_detection_with_config_file() { // Should detect React Native with high confidence due to config file let react_native = technologies.iter().find(|t| t.name == "React Native"); - + assert!(react_native.is_some(), "React Native should be detected"); let react_native = react_native.unwrap(); - - assert!(matches!(react_native.category, TechnologyCategory::FrontendFramework)); + + assert!(matches!( + react_native.category, + TechnologyCategory::FrontendFramework + )); assert!(react_native.is_primary); assert!(react_native.confidence > 0.9); // High confidence from config file detection } @@ -171,19 +169,22 @@ fn test_expo_react_native_detection_should_not_detect_nextjs() { dev_dependencies: vec![], package_manager: Some("npm".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Expo as primary, not Next.js let expo = technologies.iter().find(|t| t.name == "Expo"); let nextjs = technologies.iter().find(|t| t.name == "Next.js"); - + assert!(expo.is_some(), "Should detect Expo"); assert!(expo.unwrap().is_primary, "Expo should be primary"); - assert!(nextjs.is_none(), "Should not detect Next.js in Expo project"); + assert!( + nextjs.is_none(), + "Should not detect Next.js in Expo project" + ); } #[test] @@ -198,21 +199,19 @@ fn test_encore_backend_detection() { std::path::PathBuf::from("service/user.go"), std::path::PathBuf::from("encore.app"), ], - main_dependencies: vec![ - "encore.dev".to_string(), - ], + main_dependencies: vec!["encore.dev".to_string()], dev_dependencies: vec![], package_manager: Some("go mod".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Encore as primary let encore = technologies.iter().find(|t| t.name == "Encore"); - + assert!(encore.is_some(), "Should detect Encore"); assert!(encore.unwrap().is_primary, "Encore should be primary"); } @@ -235,19 +234,22 @@ fn test_encore_detection_should_not_detect_nextjs() { dev_dependencies: vec![], package_manager: Some("npm".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Should detect Encore as primary, not Next.js let encore = technologies.iter().find(|t| t.name == "Encore"); let nextjs = technologies.iter().find(|t| t.name == "Next.js"); - + assert!(encore.is_some(), "Should detect Encore"); assert!(encore.unwrap().is_primary, "Encore should be primary"); - assert!(nextjs.is_none(), "Should not detect Next.js in Encore project"); + assert!( + nextjs.is_none(), + "Should not detect Next.js in Encore project" + ); } #[test] @@ -299,26 +301,31 @@ fn test_false_positive_expo_detection_in_pure_typescript_project() { dev_dependencies: vec![], package_manager: Some("npm".to_string()), }; - + let config = AnalysisConfig::default(); let project_root = Path::new("."); - + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); - + // Print all detected technologies for debugging println!("Detected technologies:"); for tech in &technologies { - println!(" - {} (confidence: {:.2}, primary: {})", tech.name, tech.confidence, tech.is_primary); + println!( + " - {} (confidence: {:.2}, primary: {})", + tech.name, tech.confidence, tech.is_primary + ); } - + // Should NOT detect Expo in this pure TypeScript project let expo = technologies.iter().find(|t| t.name == "Expo"); - + if let Some(expo_tech) = expo { println!("ERROR: Expo incorrectly detected!"); println!(" Confidence: {}", expo_tech.confidence); println!(" Is primary: {}", expo_tech.is_primary); - panic!("Expo should NOT be detected in a pure TypeScript project without Expo dependencies"); + panic!( + "Expo should NOT be detected in a pure TypeScript project without Expo dependencies" + ); } else { println!("SUCCESS: Expo not detected (as expected)"); } @@ -351,15 +358,70 @@ fn test_legitimate_expo_detection_still_works() { // Should detect Expo with high confidence due to config file and proper dependencies let expo = technologies.iter().find(|t| t.name == "Expo"); - - assert!(expo.is_some(), "Expo should be detected with proper dependencies"); + + assert!( + expo.is_some(), + "Expo should be detected with proper dependencies" + ); let expo = expo.unwrap(); - + assert!(matches!(expo.category, TechnologyCategory::MetaFramework)); assert!(expo.is_primary); assert!(expo.confidence > 0.9); // High confidence from config file and dependencies - + println!("SUCCESS: Expo correctly detected with legitimate dependencies"); println!(" Confidence: {}", expo.confidence); println!(" Is primary: {}", expo.is_primary); } + +#[test] +fn nextjs_detects_with_exact_app_folder() { + let language = DetectedLanguage { + name: "TypeScript".to_string(), + version: Some("5.0.0".to_string()), + confidence: 0.95, + files: vec![ + std::path::PathBuf::from("src/app/page.tsx"), + std::path::PathBuf::from("src/app/layout.tsx"), + ], + main_dependencies: vec![], + dev_dependencies: vec![], + package_manager: Some("npm".to_string()), + }; + + let config = AnalysisConfig::default(); + let project_root = Path::new("."); + + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); + + assert!( + technologies.iter().any(|t| t.name == "Next.js"), + "Expected Next.js to be detected when using 'app' folder" + ); +} + +#[test] +fn nextjs_does_not_detect_with_application_folder() { + let language = DetectedLanguage { + name: "TypeScript".to_string(), + version: Some("5.0.0".to_string()), + confidence: 0.95, + files: vec![ + std::path::PathBuf::from("src/application/page.tsx"), + std::path::PathBuf::from("src/application/layout.tsx"), + ], + main_dependencies: vec![], + dev_dependencies: vec![], + package_manager: Some("npm".to_string()), + }; + + let config = AnalysisConfig::default(); + let project_root = Path::new("."); + + let technologies = detect_frameworks(project_root, &[language], &config).unwrap(); + // Should not detect by structure because 'application' must not match 'app' + assert!( + !technologies.iter().any(|t| t.name == "Next.js"), + "Should not detect Next.js when folder is 'application'" + ); +}