diff --git a/Cargo.lock b/Cargo.lock index e271b989..45ce1e29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,7 +181,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -410,15 +410,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d" dependencies = [ "encode_unicode", "libc", "once_cell", "unicode-width 0.2.0", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2090,14 +2090,14 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.11" +version = "0.17.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "4adb2ee6ad319a912210a36e56e3623555817bcc877a7e6e8802d1d69c4d8056" dependencies = [ "console", - "number_prefix", "portable-atomic", "unicode-width 0.2.0", + "unit-prefix", "web-time", ] @@ -2382,12 +2382,6 @@ dependencies = [ "libc", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "object" version = "0.36.7" @@ -2479,7 +2473,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -4008,6 +4002,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4297,7 +4297,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -4306,7 +4306,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", ] [[package]] @@ -4315,14 +4324,30 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -4331,48 +4356,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.7.10" diff --git a/src/analyzer/context/analysis.rs b/src/analyzer/context/analysis.rs new file mode 100644 index 00000000..ce2883db --- /dev/null +++ b/src/analyzer/context/analysis.rs @@ -0,0 +1,102 @@ +use crate::analyzer::{ + AnalysisConfig, BuildScript, DetectedLanguage, DetectedTechnology, EntryPoint, EnvVar, Port, + ProjectType, +}; +use crate::error::Result; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use super::file_analyzers::{docker, env, makefile}; +use super::language_analyzers::{go, javascript, jvm, python, rust}; +use super::microservices; +use super::project_type; +use super::tech_specific; + +/// Project context information +pub struct ProjectContext { + pub entry_points: Vec, + pub ports: Vec, + pub environment_variables: Vec, + pub project_type: ProjectType, + pub build_scripts: Vec, +} + +/// Analyzes project context including entry points, ports, and environment variables +pub fn analyze_context( + project_root: &Path, + languages: &[DetectedLanguage], + technologies: &[DetectedTechnology], + config: &AnalysisConfig, +) -> Result { + log::info!("Analyzing project context"); + + let mut entry_points = Vec::new(); + let mut ports = HashSet::new(); + let mut env_vars = HashMap::new(); + let mut build_scripts = Vec::new(); + + // Analyze based on detected languages + for language in languages { + match language.name.as_str() { + "JavaScript" | "TypeScript" => { + javascript::analyze_node_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; + } + "Python" => { + python::analyze_python_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; + } + "Rust" => { + rust::analyze_rust_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; + } + "Go" => { + go::analyze_go_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; + } + "Java" | "Kotlin" => { + jvm::analyze_jvm_project(project_root, &mut ports, &mut env_vars, &mut build_scripts, config)?; + } + _ => {} + } + } + + // Analyze common configuration files + docker::analyze_docker_files(project_root, &mut ports, &mut env_vars)?; + env::analyze_env_files(project_root, &mut env_vars)?; + makefile::analyze_makefile(project_root, &mut build_scripts)?; + + // Technology-specific analysis + for technology in technologies { + tech_specific::analyze_technology_specifics(technology, project_root, &mut entry_points, &mut ports)?; + } + + // Detect microservices structure + let microservices = microservices::detect_microservices_structure(project_root)?; + + // Determine project type + let ports_vec: Vec = ports.iter().cloned().collect(); + let project_type = project_type::determine_project_type_with_structure( + languages, + technologies, + &entry_points, + &ports_vec, + µservices, + ); + + // Convert collections to vectors + let ports: Vec = ports.into_iter().collect(); + let environment_variables: Vec = env_vars + .into_iter() + .map(|(name, (default, required, desc))| EnvVar { + name, + default_value: default, + required, + description: desc, + }) + .collect(); + + Ok(ProjectContext { + entry_points, + ports, + environment_variables, + project_type, + build_scripts, + }) +} \ No newline at end of file diff --git a/src/analyzer/context/file_analyzers/docker.rs b/src/analyzer/context/file_analyzers/docker.rs new file mode 100644 index 00000000..c4bacf54 --- /dev/null +++ b/src/analyzer/context/file_analyzers/docker.rs @@ -0,0 +1,297 @@ +use crate::analyzer::{context::helpers::create_regex, Port, Protocol}; +use crate::common::file_utils::is_readable_file; +use crate::error::{AnalysisError, Result}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +/// Analyzes Docker files for ports and environment variables +pub(crate) fn analyze_docker_files( + root: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, +) -> Result<()> { + let dockerfile = root.join("Dockerfile"); + + if is_readable_file(&dockerfile) { + let content = std::fs::read_to_string(&dockerfile)?; + + // Look for EXPOSE directives + let expose_regex = create_regex(r"EXPOSE\s+(\d{1,5})(?:/(\w+))?")?; + for cap in expose_regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + let protocol = cap.get(2) + .and_then(|p| match p.as_str().to_lowercase().as_str() { + "tcp" => Some(Protocol::Tcp), + "udp" => Some(Protocol::Udp), + _ => None, + }) + .unwrap_or(Protocol::Tcp); + + ports.insert(Port { + number: port, + protocol, + description: Some("Exposed in Dockerfile".to_string()), + }); + } + } + } + + // Look for ENV directives + let env_regex = create_regex(r"ENV\s+([A-Z_][A-Z0-9_]*)\s+(.+)")?; + for cap in env_regex.captures_iter(&content) { + if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) { + let var_name = name.as_str().to_string(); + let var_value = value.as_str().trim().to_string(); + env_vars.entry(var_name).or_insert((Some(var_value), false, None)); + } + } + } + + // Check docker-compose files + let compose_files = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]; + for compose_file in &compose_files { + let path = root.join(compose_file); + if is_readable_file(&path) { + analyze_docker_compose(&path, ports, env_vars)?; + break; + } + } + + Ok(()) +} + +/// Analyzes docker-compose files +fn analyze_docker_compose( + path: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, +) -> Result<()> { + let content = std::fs::read_to_string(path)?; + let value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| AnalysisError::InvalidStructure(format!("Invalid YAML: {}", e)))?; + + if let Some(services) = value.get("services").and_then(|s| s.as_mapping()) { + for (service_name, service) in services { + let service_name_str = service_name.as_str().unwrap_or("unknown"); + + // Determine service type based on image, name, and other indicators + let service_type = determine_service_type(service_name_str, service); + + // Extract ports + if let Some(service_ports) = service.get("ports").and_then(|p| p.as_sequence()) { + for port_entry in service_ports { + if let Some(port_str) = port_entry.as_str() { + // Parse port mappings like "8080:80" or just "80" + let parts: Vec<&str> = port_str.split(':').collect(); + + let (external_port, internal_port, protocol_suffix) = if parts.len() >= 2 { + // Format: "external:internal" or "external:internal/protocol" + let external = parts[0].trim(); + let internal_parts: Vec<&str> = parts[1].split('/').collect(); + let internal = internal_parts[0].trim(); + let protocol = internal_parts.get(1).map(|p| p.trim()); + (external, internal, protocol) + } else { + // Format: just "port" or "port/protocol" + let port_parts: Vec<&str> = parts[0].split('/').collect(); + let port = port_parts[0].trim(); + let protocol = port_parts.get(1).map(|p| p.trim()); + (port, port, protocol) + }; + + // Determine protocol + let protocol = match protocol_suffix { + Some("udp") => Protocol::Udp, + _ => Protocol::Tcp, + }; + + // Create descriptive port entry + if let Ok(port) = external_port.parse::() { + let description = create_port_description(&service_type, service_name_str, external_port, internal_port); + + ports.insert(Port { + number: port, + protocol, + description: Some(description), + }); + } + } + } + } + + // Extract environment variables with context + if let Some(env) = service.get("environment") { + let env_context = format!(" ({})", service_type.as_str()); + + if let Some(env_map) = env.as_mapping() { + for (key, value) in env_map { + if let Some(key_str) = key.as_str() { + let val_str = value.as_str().map(|s| s.to_string()); + let description = get_env_var_description(key_str, &service_type); + env_vars.entry(key_str.to_string()) + .or_insert((val_str, false, description.or_else(|| Some(env_context.clone())))); + } + } + } else if let Some(env_list) = env.as_sequence() { + for item in env_list { + if let Some(env_str) = item.as_str() { + if let Some(eq_pos) = env_str.find('=') { + let (key, value) = env_str.split_at(eq_pos); + let value = &value[1..]; // Skip the '=' + let description = get_env_var_description(key, &service_type); + env_vars.entry(key.to_string()) + .or_insert((Some(value.to_string()), false, description.or_else(|| Some(env_context.clone())))); + } + } + } + } + } + } + } + + Ok(()) +} + +/// Service types found in Docker Compose +#[derive(Debug, Clone)] +enum ServiceType { + PostgreSQL, + MySQL, + MongoDB, + Redis, + RabbitMQ, + Kafka, + Elasticsearch, + Application, + Nginx, + Unknown, +} + +impl ServiceType { + fn as_str(&self) -> &'static str { + match self { + ServiceType::PostgreSQL => "PostgreSQL database", + ServiceType::MySQL => "MySQL database", + ServiceType::MongoDB => "MongoDB database", + ServiceType::Redis => "Redis cache", + ServiceType::RabbitMQ => "RabbitMQ message broker", + ServiceType::Kafka => "Kafka message broker", + ServiceType::Elasticsearch => "Elasticsearch search engine", + ServiceType::Application => "Application service", + ServiceType::Nginx => "Nginx web server", + ServiceType::Unknown => "Service", + } + } +} + +/// Determines the type of service based on various indicators +fn determine_service_type(name: &str, service: &serde_yaml::Value) -> ServiceType { + let name_lower = name.to_lowercase(); + + // Check service name + if name_lower.contains("postgres") || name_lower.contains("pg") || name_lower.contains("psql") { + return ServiceType::PostgreSQL; + } else if name_lower.contains("mysql") || name_lower.contains("mariadb") { + return ServiceType::MySQL; + } else if name_lower.contains("mongo") { + return ServiceType::MongoDB; + } else if name_lower.contains("redis") { + return ServiceType::Redis; + } else if name_lower.contains("rabbit") || name_lower.contains("amqp") { + return ServiceType::RabbitMQ; + } else if name_lower.contains("kafka") { + return ServiceType::Kafka; + } else if name_lower.contains("elastic") || name_lower.contains("es") { + return ServiceType::Elasticsearch; + } else if name_lower.contains("nginx") || name_lower.contains("proxy") { + return ServiceType::Nginx; + } + + // Check image name + if let Some(image) = service.get("image").and_then(|i| i.as_str()) { + let image_lower = image.to_lowercase(); + if image_lower.contains("postgres") { + return ServiceType::PostgreSQL; + } else if image_lower.contains("mysql") || image_lower.contains("mariadb") { + return ServiceType::MySQL; + } else if image_lower.contains("mongo") { + return ServiceType::MongoDB; + } else if image_lower.contains("redis") { + return ServiceType::Redis; + } else if image_lower.contains("rabbitmq") { + return ServiceType::RabbitMQ; + } else if image_lower.contains("kafka") { + return ServiceType::Kafka; + } else if image_lower.contains("elastic") { + return ServiceType::Elasticsearch; + } else if image_lower.contains("nginx") { + return ServiceType::Nginx; + } + } + + // Check environment variables for clues + if let Some(env) = service.get("environment") { + if let Some(env_map) = env.as_mapping() { + for (key, _) in env_map { + if let Some(key_str) = key.as_str() { + if key_str.contains("POSTGRES") || key_str.contains("PGPASSWORD") { + return ServiceType::PostgreSQL; + } else if key_str.contains("MYSQL") { + return ServiceType::MySQL; + } else if key_str.contains("MONGO") { + return ServiceType::MongoDB; + } + } + } + } + } + + // Check if it has a build context (likely application) + if service.get("build").is_some() { + return ServiceType::Application; + } + + ServiceType::Unknown +} + +/// Creates a descriptive port description based on service type +fn create_port_description(service_type: &ServiceType, service_name: &str, external: &str, internal: &str) -> String { + let base_desc = match service_type { + ServiceType::PostgreSQL => format!("PostgreSQL database ({})", service_name), + ServiceType::MySQL => format!("MySQL database ({})", service_name), + ServiceType::MongoDB => format!("MongoDB database ({})", service_name), + ServiceType::Redis => format!("Redis cache ({})", service_name), + ServiceType::RabbitMQ => format!("RabbitMQ message broker ({})", service_name), + ServiceType::Kafka => format!("Kafka message broker ({})", service_name), + ServiceType::Elasticsearch => format!("Elasticsearch ({})", service_name), + ServiceType::Nginx => format!("Nginx proxy ({})", service_name), + ServiceType::Application => format!("Application service ({})", service_name), + ServiceType::Unknown => format!("Docker service ({})", service_name), + }; + + if external != internal { + format!("{} - external:{}, internal:{}", base_desc, external, internal) + } else { + format!("{} - port {}", base_desc, external) + } +} + +/// Gets a descriptive context for environment variables based on service type +fn get_env_var_description(var_name: &str, _service_type: &ServiceType) -> Option { + match var_name { + "POSTGRES_PASSWORD" | "POSTGRES_USER" | "POSTGRES_DB" => + Some("PostgreSQL configuration".to_string()), + "MYSQL_ROOT_PASSWORD" | "MYSQL_PASSWORD" | "MYSQL_USER" | "MYSQL_DATABASE" => + Some("MySQL configuration".to_string()), + "MONGO_INITDB_ROOT_USERNAME" | "MONGO_INITDB_ROOT_PASSWORD" => + Some("MongoDB configuration".to_string()), + "REDIS_PASSWORD" => Some("Redis configuration".to_string()), + "RABBITMQ_DEFAULT_USER" | "RABBITMQ_DEFAULT_PASS" => + Some("RabbitMQ configuration".to_string()), + "DATABASE_URL" | "DB_CONNECTION_STRING" => + Some("Database connection string".to_string()), + "GOOGLE_APPLICATION_CREDENTIALS" => + Some("Google Cloud service account credentials".to_string()), + _ => None, + } +} \ No newline at end of file diff --git a/src/analyzer/context/file_analyzers/env.rs b/src/analyzer/context/file_analyzers/env.rs new file mode 100644 index 00000000..49b0dcab --- /dev/null +++ b/src/analyzer/context/file_analyzers/env.rs @@ -0,0 +1,40 @@ +use crate::common::file_utils::is_readable_file; +use crate::error::Result; +use std::collections::HashMap; +use std::path::Path; + +/// Analyzes .env files +pub(crate) fn analyze_env_files( + root: &Path, + env_vars: &mut HashMap, bool, Option)>, +) -> Result<()> { + let env_files = [".env", ".env.example", ".env.local", ".env.development", ".env.production"]; + + for env_file in &env_files { + let path = root.join(env_file); + if is_readable_file(&path) { + let content = std::fs::read_to_string(&path)?; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(eq_pos) = line.find('=') { + let (key, value) = line.split_at(eq_pos); + let key = key.trim(); + let value = value[1..].trim(); // Skip the '=' + + // Check if it's marked as required (common convention) + let required = value.is_empty() || value == "required" || value == "REQUIRED"; + let actual_value = if required { None } else { Some(value.to_string()) }; + + env_vars.entry(key.to_string()).or_insert((actual_value, required, None)); + } + } + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/file_analyzers/makefile.rs b/src/analyzer/context/file_analyzers/makefile.rs new file mode 100644 index 00000000..a3206927 --- /dev/null +++ b/src/analyzer/context/file_analyzers/makefile.rs @@ -0,0 +1,65 @@ +use crate::analyzer::{context::helpers::create_regex, BuildScript}; +use crate::common::file_utils::is_readable_file; +use crate::error::Result; +use std::path::Path; + +/// Analyzes Makefile for build scripts +pub(crate) fn analyze_makefile( + root: &Path, + build_scripts: &mut Vec, +) -> Result<()> { + let makefiles = ["Makefile", "makefile"]; + + for makefile in &makefiles { + let path = root.join(makefile); + if is_readable_file(&path) { + let content = std::fs::read_to_string(&path)?; + + // Simple Makefile target extraction + let target_regex = create_regex(r"^([a-zA-Z0-9_-]+):\s*(?:[^\n]*)?$")?; + let mut in_recipe = false; + let mut current_target = String::new(); + let mut current_command = String::new(); + + for line in content.lines() { + if let Some(cap) = target_regex.captures(line) { + // Save previous target if any + if !current_target.is_empty() && !current_command.is_empty() { + build_scripts.push(BuildScript { + name: current_target.clone(), + command: format!("make {}", current_target), + description: None, + is_default: current_target == "run" || current_target == "start", + }); + } + + if let Some(target) = cap.get(1) { + current_target = target.as_str().to_string(); + current_command.clear(); + in_recipe = true; + } + } else if in_recipe && line.starts_with('\t') { + if current_command.is_empty() { + current_command = line.trim().to_string(); + } + } else if !line.trim().is_empty() { + in_recipe = false; + } + } + + // Save last target + if !current_target.is_empty() && !current_command.is_empty() { + build_scripts.push(BuildScript { + name: current_target.clone(), + command: format!("make {}", current_target), + description: None, + is_default: current_target == "run" || current_target == "start", + }); + } + + break; + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/file_analyzers/mod.rs b/src/analyzer/context/file_analyzers/mod.rs new file mode 100644 index 00000000..3c5df224 --- /dev/null +++ b/src/analyzer/context/file_analyzers/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod docker; +pub(crate) mod env; +pub(crate) mod makefile; \ No newline at end of file diff --git a/src/analyzer/context/helpers.rs b/src/analyzer/context/helpers.rs new file mode 100644 index 00000000..456c5669 --- /dev/null +++ b/src/analyzer/context/helpers.rs @@ -0,0 +1,51 @@ +use crate::analyzer::{Port, Protocol}; +use crate::error::{AnalysisError, Result}; +use regex::Regex; +use std::collections::HashSet; + +/// Helper function to create a regex with proper error handling +pub fn create_regex(pattern: &str) -> Result { + Regex::new(pattern).map_err(|e| { + AnalysisError::InvalidStructure(format!("Invalid regex pattern '{}': {}", pattern, e)).into() + }) +} + +/// Extracts ports from command strings +pub fn extract_ports_from_command(command: &str, ports: &mut HashSet) { + // Look for common port patterns in commands + let patterns = [ + r"-p\s+(\d{1,5})", + r"--port\s+(\d{1,5})", + r"--port=(\d{1,5})", + r"PORT=(\d{1,5})", + ]; + + for pattern in &patterns { + if let Ok(regex) = Regex::new(pattern) { + for cap in regex.captures_iter(command) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("Port from command".to_string()), + }); + } + } + } + } + } +} + +/// Helper function to get script description +pub fn get_script_description(name: &str) -> Option { + match name { + "start" => Some("Start the application".to_string()), + "dev" => Some("Start development server".to_string()), + "build" => Some("Build the application".to_string()), + "test" => Some("Run tests".to_string()), + "lint" => Some("Run linter".to_string()), + "format" => Some("Format code".to_string()), + _ => None, + } +} \ No newline at end of file diff --git a/src/analyzer/context/language_analyzers/go.rs b/src/analyzer/context/language_analyzers/go.rs new file mode 100644 index 00000000..2f508701 --- /dev/null +++ b/src/analyzer/context/language_analyzers/go.rs @@ -0,0 +1,130 @@ +use crate::analyzer::{context::helpers::create_regex, AnalysisConfig, BuildScript, EntryPoint, Port, Protocol}; +use crate::common::file_utils::{is_readable_file, read_file_safe}; +use crate::error::Result; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +/// Analyzes Go projects +pub(crate) fn analyze_go_project( + root: &Path, + entry_points: &mut Vec, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + build_scripts: &mut Vec, + config: &AnalysisConfig, +) -> Result<()> { + // Check for main.go + let main_go = root.join("main.go"); + if is_readable_file(&main_go) { + entry_points.push(EntryPoint { + file: main_go.clone(), + function: Some("main".to_string()), + command: Some("go run main.go".to_string()), + }); + + scan_go_file_for_context(&main_go, ports, env_vars, config)?; + } + + // Check cmd directory for multiple binaries + let cmd_dir = root.join("cmd"); + if cmd_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&cmd_dir) { + for entry in entries.flatten() { + if entry.file_type()?.is_dir() { + let main_file = entry.path().join("main.go"); + if is_readable_file(&main_file) { + let cmd_name = entry.file_name().to_string_lossy().to_string(); + entry_points.push(EntryPoint { + file: main_file.clone(), + function: Some("main".to_string()), + command: Some(format!("go run ./cmd/{}", cmd_name)), + }); + + scan_go_file_for_context(&main_file, ports, env_vars, config)?; + } + } + } + } + } + + // Common Go build commands + build_scripts.extend(vec![ + BuildScript { + name: "build".to_string(), + command: "go build".to_string(), + description: Some("Build the project".to_string()), + is_default: false, + }, + BuildScript { + name: "test".to_string(), + command: "go test ./...".to_string(), + description: Some("Run tests".to_string()), + is_default: false, + }, + BuildScript { + name: "run".to_string(), + command: "go run .".to_string(), + description: Some("Run the application".to_string()), + is_default: true, + }, + BuildScript { + name: "mod-download".to_string(), + command: "go mod download".to_string(), + description: Some("Download dependencies".to_string()), + is_default: false, + }, + ]); + + Ok(()) +} + +/// Scans Go files for context information +fn scan_go_file_for_context( + path: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + config: &AnalysisConfig, +) -> Result<()> { + let content = read_file_safe(path, config.max_file_size)?; + + // Look for port bindings + let port_patterns = [ + r#"Listen\s*\(\s*":(\d{1,5})"\s*\)"#, + r#"ListenAndServe\s*\(\s*":(\d{1,5})"\s*,"#, + r#"Addr:\s*":(\d{1,5})""#, + r"PORT[^=]*=\s*(\d{1,5})", + ]; + + for pattern in &port_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("Go web server".to_string()), + }); + } + } + } + } + + // Look for environment variable usage + let env_patterns = [ + r#"os\.Getenv\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, + r#"os\.LookupEnv\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, + ]; + + for pattern in &env_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(var_name) = cap.get(1) { + let name = var_name.as_str().to_string(); + env_vars.entry(name.clone()).or_insert((None, false, None)); + } + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/language_analyzers/javascript.rs b/src/analyzer/context/language_analyzers/javascript.rs new file mode 100644 index 00000000..f604fdf6 --- /dev/null +++ b/src/analyzer/context/language_analyzers/javascript.rs @@ -0,0 +1,148 @@ +use crate::analyzer::{context::helpers::{create_regex, extract_ports_from_command, get_script_description}, AnalysisConfig, BuildScript, EntryPoint, Port, Protocol}; +use crate::common::file_utils::{is_readable_file, read_file_safe}; +use crate::error::{AnalysisError, Result}; +use regex::Regex; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +/// Analyzes Node.js/JavaScript/TypeScript projects +pub(crate) fn analyze_node_project( + root: &Path, + entry_points: &mut Vec, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + build_scripts: &mut Vec, + config: &AnalysisConfig, +) -> Result<()> { + let package_json_path = root.join("package.json"); + + if is_readable_file(&package_json_path) { + let content = read_file_safe(&package_json_path, config.max_file_size)?; + let package_json: Value = serde_json::from_str(&content)?; + + // Extract scripts + if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) { + for (name, command) in scripts { + if let Some(cmd) = command.as_str() { + build_scripts.push(BuildScript { + name: name.clone(), + command: cmd.to_string(), + description: get_script_description(name), + is_default: name == "start" || name == "dev", + }); + + // Look for ports in scripts + extract_ports_from_command(cmd, ports); + } + } + } + + // Find main entry point + if let Some(main) = package_json.get("main").and_then(|m| m.as_str()) { + entry_points.push(EntryPoint { + file: root.join(main), + function: None, + command: Some(format!("node {}", main)), + }); + } + + // Check common entry files + let common_entries = ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js", "main.ts"]; + for entry in &common_entries { + let path = root.join(entry); + if is_readable_file(&path) { + scan_js_file_for_context(&path, ports, env_vars, config)?; + } + } + + // Check src directory + let src_dir = root.join("src"); + if src_dir.is_dir() { + for entry in &common_entries { + let path = src_dir.join(entry); + if is_readable_file(&path) { + scan_js_file_for_context(&path, ports, env_vars, config)?; + } + } + } + } + + Ok(()) +} + +/// Scans JavaScript/TypeScript files for context information +fn scan_js_file_for_context( + path: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + config: &AnalysisConfig, +) -> Result<()> { + let content = read_file_safe(path, config.max_file_size)?; + + // Look for port assignments + let port_regex = Regex::new(r"(?:PORT|port)\s*[=:]\s*(?:process\.env\.PORT\s*\|\|\s*)?(\d{1,5})") + .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex: {}", e)))?; + for cap in port_regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("HTTP server port".to_string()), + }); + } + } + } + + // Look for app.listen() calls + let listen_regex = Regex::new(r"\.listen\s*\(\s*(\d{1,5})") + .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex: {}", e)))?; + for cap in listen_regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("Express/HTTP server".to_string()), + }); + } + } + } + + // Look for environment variable usage + let env_regex = Regex::new(r"process\.env\.([A-Z_][A-Z0-9_]*)") + .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex: {}", e)))?; + for cap in env_regex.captures_iter(&content) { + if let Some(var_name) = cap.get(1) { + let name = var_name.as_str().to_string(); + if !name.starts_with("NODE_") { // Skip Node.js internal vars + env_vars.entry(name.clone()).or_insert((None, false, None)); + } + } + } + + // Look for Encore.dev imports and patterns + if content.contains("encore.dev") { + // Encore uses specific patterns for config and database + let encore_patterns = [ + (r#"secret\s*\(\s*['"]([A-Z_][A-Z0-9_]*)['"]"#, "Encore secret configuration"), + (r#"SQLDatabase\s*\(\s*['"](\w+)['"]"#, "Encore database"), + ]; + + for (pattern, description) in &encore_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(match_str) = cap.get(1) { + let name = match_str.as_str(); + if pattern.contains("secret") { + env_vars.entry(name.to_string()) + .or_insert((None, true, Some(description.to_string()))); + } + } + } + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/language_analyzers/jvm.rs b/src/analyzer/context/language_analyzers/jvm.rs new file mode 100644 index 00000000..9e83156e --- /dev/null +++ b/src/analyzer/context/language_analyzers/jvm.rs @@ -0,0 +1,118 @@ +use crate::analyzer::{context::helpers::create_regex, AnalysisConfig, BuildScript, Port, Protocol}; +use crate::common::file_utils::{is_readable_file, read_file_safe}; +use crate::error::Result; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +/// Analyzes JVM projects (Java/Kotlin) +pub(crate) fn analyze_jvm_project( + root: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + build_scripts: &mut Vec, + config: &AnalysisConfig, +) -> Result<()> { + // Check for Maven + let pom_xml = root.join("pom.xml"); + if is_readable_file(&pom_xml) { + build_scripts.extend(vec![ + BuildScript { + name: "build".to_string(), + command: "mvn clean package".to_string(), + description: Some("Build with Maven".to_string()), + is_default: false, + }, + BuildScript { + name: "test".to_string(), + command: "mvn test".to_string(), + description: Some("Run tests".to_string()), + is_default: false, + }, + BuildScript { + name: "run".to_string(), + command: "mvn spring-boot:run".to_string(), + description: Some("Run Spring Boot application".to_string()), + is_default: true, + }, + ]); + } + + // Check for Gradle + let gradle_files = ["build.gradle", "build.gradle.kts"]; + for gradle_file in &gradle_files { + if is_readable_file(&root.join(gradle_file)) { + build_scripts.extend(vec![ + BuildScript { + name: "build".to_string(), + command: "./gradlew build".to_string(), + description: Some("Build with Gradle".to_string()), + is_default: false, + }, + BuildScript { + name: "test".to_string(), + command: "./gradlew test".to_string(), + description: Some("Run tests".to_string()), + is_default: false, + }, + BuildScript { + name: "run".to_string(), + command: "./gradlew bootRun".to_string(), + description: Some("Run Spring Boot application".to_string()), + is_default: true, + }, + ]); + break; + } + } + + // Look for application properties + let app_props_locations = [ + "src/main/resources/application.properties", + "src/main/resources/application.yml", + "src/main/resources/application.yaml", + ]; + + for props_path in &app_props_locations { + let full_path = root.join(props_path); + if is_readable_file(&full_path) { + analyze_application_properties(&full_path, ports, env_vars, config)?; + } + } + + Ok(()) +} + +/// Analyzes application properties files +fn analyze_application_properties( + path: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + config: &AnalysisConfig, +) -> Result<()> { + let content = read_file_safe(path, config.max_file_size)?; + + // Look for server.port + let port_regex = create_regex(r"server\.port\s*[=:]\s*(\d{1,5})")?; + for cap in port_regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("Spring Boot server".to_string()), + }); + } + } + } + + // Look for ${ENV_VAR} placeholders + let env_regex = create_regex(r"\$\{([A-Z_][A-Z0-9_]*)\}")?; + for cap in env_regex.captures_iter(&content) { + if let Some(var_name) = cap.get(1) { + let name = var_name.as_str().to_string(); + env_vars.entry(name.clone()).or_insert((None, false, None)); + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/language_analyzers/mod.rs b/src/analyzer/context/language_analyzers/mod.rs new file mode 100644 index 00000000..ca3d42f0 --- /dev/null +++ b/src/analyzer/context/language_analyzers/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod go; +pub(crate) mod javascript; +pub(crate) mod jvm; +pub(crate) mod python; +pub(crate) mod rust; \ No newline at end of file diff --git a/src/analyzer/context/language_analyzers/python.rs b/src/analyzer/context/language_analyzers/python.rs new file mode 100644 index 00000000..d1947106 --- /dev/null +++ b/src/analyzer/context/language_analyzers/python.rs @@ -0,0 +1,146 @@ +use crate::analyzer::{context::helpers::create_regex, AnalysisConfig, BuildScript, EntryPoint, Port, Protocol}; +use crate::common::file_utils::{is_readable_file, read_file_safe}; +use crate::error::Result; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Analyzes Python projects +pub(crate) fn analyze_python_project( + root: &Path, + entry_points: &mut Vec, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + build_scripts: &mut Vec, + config: &AnalysisConfig, +) -> Result<()> { + // Check for common Python entry points + let common_entries = ["main.py", "app.py", "wsgi.py", "asgi.py", "manage.py", "run.py", "__main__.py"]; + + for entry in &common_entries { + let path = root.join(entry); + if is_readable_file(&path) { + scan_python_file_for_context(&path, entry_points, ports, env_vars, config)?; + } + } + + // Check setup.py for entry points + let setup_py = root.join("setup.py"); + if is_readable_file(&setup_py) { + let content = read_file_safe(&setup_py, config.max_file_size)?; + + // Look for console_scripts + let console_regex = create_regex(r#"console_scripts['"]\s*:\s*\[(.*?)\]"#)?; + if let Some(cap) = console_regex.captures(&content) { + if let Some(scripts) = cap.get(1) { + let script_regex = create_regex(r#"['"](\w+)\s*=\s*([\w\.]+):(\w+)"#)?; + for script_cap in script_regex.captures_iter(scripts.as_str()) { + if let (Some(name), Some(module), Some(func)) = + (script_cap.get(1), script_cap.get(2), script_cap.get(3)) { + entry_points.push(EntryPoint { + file: PathBuf::from(format!("{}.py", module.as_str().replace('.', "/"))), + function: Some(func.as_str().to_string()), + command: Some(name.as_str().to_string()), + }); + } + } + } + } + } + + // Check pyproject.toml for scripts + let pyproject = root.join("pyproject.toml"); + if is_readable_file(&pyproject) { + let content = read_file_safe(&pyproject, config.max_file_size)?; + if let Ok(toml_value) = toml::from_str::(&content) { + // Extract build scripts from poetry + if let Some(scripts) = toml_value.get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("scripts")) + .and_then(|s| s.as_table()) { + for (name, cmd) in scripts { + if let Some(command) = cmd.as_str() { + build_scripts.push(BuildScript { + name: name.clone(), + command: command.to_string(), + description: None, + is_default: name == "start" || name == "run", + }); + } + } + } + } + } + + // Common Python build commands + build_scripts.push(BuildScript { + name: "install".to_string(), + command: "pip install -r requirements.txt".to_string(), + description: Some("Install dependencies".to_string()), + is_default: false, + }); + + Ok(()) +} + +/// Scans Python files for context information +fn scan_python_file_for_context( + path: &Path, + entry_points: &mut Vec, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + config: &AnalysisConfig, +) -> Result<()> { + let content = read_file_safe(path, config.max_file_size)?; + + // Look for Flask/FastAPI/Django port configurations + let port_patterns = [ + r"port\s*=\s*(\d{1,5})", + r"PORT\s*=\s*(\d{1,5})", + r"\.run\s*\([^)]*port\s*=\s*(\d{1,5})", + r"uvicorn\.run\s*\([^)]*port\s*=\s*(\d{1,5})", + ]; + + for pattern in &port_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("Python web server".to_string()), + }); + } + } + } + } + + // Look for environment variable usage + let env_patterns = [ + r#"os\.environ\.get\s*\(\s*["']([A-Z_][A-Z0-9_]*)["']"#, + r#"os\.environ\s*\[\s*["']([A-Z_][A-Z0-9_]*)["']\s*\]"#, + r#"os\.getenv\s*\(\s*["']([A-Z_][A-Z0-9_]*)["']"#, + ]; + + for pattern in &env_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(var_name) = cap.get(1) { + let name = var_name.as_str().to_string(); + env_vars.entry(name.clone()).or_insert((None, false, None)); + } + } + } + + // Check if this is a main entry point + if content.contains("if __name__ == '__main__':") || + content.contains("if __name__ == \"__main__\":") { + entry_points.push(EntryPoint { + file: path.to_path_buf(), + function: Some("main".to_string()), + command: Some(format!("python {}", path.file_name().unwrap().to_string_lossy())), + }); + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/language_analyzers/rust.rs b/src/analyzer/context/language_analyzers/rust.rs new file mode 100644 index 00000000..6c3eb825 --- /dev/null +++ b/src/analyzer/context/language_analyzers/rust.rs @@ -0,0 +1,139 @@ +use crate::analyzer::{context::helpers::create_regex, AnalysisConfig, BuildScript, EntryPoint, Port, Protocol}; +use crate::common::file_utils::{is_readable_file, read_file_safe}; +use crate::error::Result; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Analyzes Rust projects +pub(crate) fn analyze_rust_project( + root: &Path, + entry_points: &mut Vec, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + build_scripts: &mut Vec, + config: &AnalysisConfig, +) -> Result<()> { + let cargo_toml = root.join("Cargo.toml"); + + if is_readable_file(&cargo_toml) { + let content = read_file_safe(&cargo_toml, config.max_file_size)?; + if let Ok(toml_value) = toml::from_str::(&content) { + // Check for binary targets + if let Some(bins) = toml_value.get("bin").and_then(|b| b.as_array()) { + for bin in bins { + if let Some(name) = bin.get("name").and_then(|n| n.as_str()) { + let path = bin.get("path") + .and_then(|p| p.as_str()) + .map(PathBuf::from) + .unwrap_or_else(|| root.join("src").join("bin").join(format!("{}.rs", name))); + + entry_points.push(EntryPoint { + file: path, + function: Some("main".to_string()), + command: Some(format!("cargo run --bin {}", name)), + }); + } + } + } + + // Default binary + if let Some(_package_name) = toml_value.get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) { + let main_rs = root.join("src").join("main.rs"); + if is_readable_file(&main_rs) { + entry_points.push(EntryPoint { + file: main_rs.clone(), + function: Some("main".to_string()), + command: Some("cargo run".to_string()), + }); + + // Scan main.rs for context + scan_rust_file_for_context(&main_rs, ports, env_vars, config)?; + } + } + } + } + + // Common Rust build commands + build_scripts.extend(vec![ + BuildScript { + name: "build".to_string(), + command: "cargo build".to_string(), + description: Some("Build the project".to_string()), + is_default: false, + }, + BuildScript { + name: "build-release".to_string(), + command: "cargo build --release".to_string(), + description: Some("Build optimized release version".to_string()), + is_default: false, + }, + BuildScript { + name: "test".to_string(), + command: "cargo test".to_string(), + description: Some("Run tests".to_string()), + is_default: false, + }, + BuildScript { + name: "run".to_string(), + command: "cargo run".to_string(), + description: Some("Run the application".to_string()), + is_default: true, + }, + ]); + + Ok(()) +} + +/// Scans Rust files for context information +fn scan_rust_file_for_context( + path: &Path, + ports: &mut HashSet, + env_vars: &mut HashMap, bool, Option)>, + config: &AnalysisConfig, +) -> Result<()> { + let content = read_file_safe(path, config.max_file_size)?; + + // Look for port bindings + let port_patterns = [ + r#"bind\s*\(\s*"[^"]*:(\d{1,5})"\s*\)"#, + r#"bind\s*\(\s*\([^,]+,\s*(\d{1,5})\)\s*\)"#, + r#"listen\s*\(\s*"[^"]*:(\d{1,5})"\s*\)"#, + r"PORT[^=]*=\s*(\d{1,5})", + ]; + + for pattern in &port_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(port_str) = cap.get(1) { + if let Ok(port) = port_str.as_str().parse::() { + ports.insert(Port { + number: port, + protocol: Protocol::Http, + description: Some("Rust web server".to_string()), + }); + } + } + } + } + + // Look for environment variable usage + let env_patterns = [ + r#"env::var\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, + r#"std::env::var\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, + r#"env!\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, + ]; + + for pattern in &env_patterns { + let regex = create_regex(pattern)?; + for cap in regex.captures_iter(&content) { + if let Some(var_name) = cap.get(1) { + let name = var_name.as_str().to_string(); + env_vars.entry(name.clone()).or_insert((None, false, None)); + } + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/context/microservices.rs b/src/analyzer/context/microservices.rs new file mode 100644 index 00000000..1aadf00c --- /dev/null +++ b/src/analyzer/context/microservices.rs @@ -0,0 +1,66 @@ +use crate::error::Result; +use std::path::Path; + +/// Represents a detected microservice within the project +#[derive(Debug)] +pub(crate) struct MicroserviceInfo { + pub name: String, + pub has_db: bool, + pub has_api: bool, +} + +/// Detects microservice structure based on directory patterns +pub(crate) fn detect_microservices_structure(project_root: &Path) -> Result> { + let mut microservices = Vec::new(); + + // Common patterns for microservice directories + let service_indicators = ["api", "service", "encore.service.ts", "main.ts", "main.go", "main.py"]; + let db_indicators = ["db", "database", "migrations", "schema", "models"]; + + // Check root-level directories + if let Ok(entries) = std::fs::read_dir(project_root) { + for entry in entries.flatten() { + if entry.file_type()?.is_dir() { + let dir_name = entry.file_name().to_string_lossy().to_string(); + let dir_path = entry.path(); + + // Skip common non-service directories + if dir_name.starts_with('.') || + ["node_modules", "target", "dist", "build", "__pycache__", "vendor"].contains(&dir_name.as_str()) { + continue; + } + + // Check if this directory looks like a service + let mut has_api = false; + let mut has_db = false; + + if let Ok(sub_entries) = std::fs::read_dir(&dir_path) { + for sub_entry in sub_entries.flatten() { + let sub_name = sub_entry.file_name().to_string_lossy().to_string(); + + // Check for API indicators + if service_indicators.iter().any(|&ind| sub_name.contains(ind)) { + has_api = true; + } + + // Check for DB indicators + if db_indicators.iter().any(|&ind| sub_name.contains(ind)) { + has_db = true; + } + } + } + + // If it has service characteristics, add it as a microservice + if has_api || has_db { + microservices.push(MicroserviceInfo { + name: dir_name, + has_db, + has_api, + }); + } + } + } + } + + Ok(microservices) +} \ No newline at end of file diff --git a/src/analyzer/context/mod.rs b/src/analyzer/context/mod.rs new file mode 100644 index 00000000..53a53ca6 --- /dev/null +++ b/src/analyzer/context/mod.rs @@ -0,0 +1,9 @@ +pub mod analysis; +pub(crate) mod file_analyzers; +pub(crate) mod helpers; +pub(crate) mod language_analyzers; +pub(crate) mod microservices; +pub(crate) mod project_type; +pub(crate) mod tech_specific; + +pub use analysis::analyze_context; \ No newline at end of file diff --git a/src/analyzer/context/project_type.rs b/src/analyzer/context/project_type.rs new file mode 100644 index 00000000..1da3cc1b --- /dev/null +++ b/src/analyzer/context/project_type.rs @@ -0,0 +1,107 @@ +use crate::analyzer::{DetectedLanguage, DetectedTechnology, EntryPoint, Port, ProjectType}; +use super::microservices::MicroserviceInfo; + +/// Enhanced project type determination including microservice structure analysis +pub(crate) fn determine_project_type_with_structure( + languages: &[DetectedLanguage], + technologies: &[DetectedTechnology], + entry_points: &[EntryPoint], + ports: &[Port], + microservices: &[MicroserviceInfo], +) -> ProjectType { + // If we have multiple services with databases, it's likely a microservice architecture + let services_with_db = microservices.iter().filter(|s| s.has_db).count(); + if services_with_db >= 2 || microservices.len() >= 3 { + return ProjectType::Microservice; + } + + // Fall back to original determination logic + determine_project_type(languages, technologies, entry_points, ports) +} + +/// Determines the project type based on analysis +fn determine_project_type( + languages: &[DetectedLanguage], + technologies: &[DetectedTechnology], + entry_points: &[EntryPoint], + ports: &[Port], +) -> ProjectType { + // Check for microservice architecture indicators + 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") + } else { + false + } + }); + + 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; + + 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 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() + .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() + .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() + .any(|t| static_generators.contains(&t.name.as_str())); + + // Determine type based on indicators + if (has_database_ports || has_multiple_services) && (has_orchestration_framework || has_api_framework) { + ProjectType::Microservice + } else if has_static_generator { + ProjectType::StaticSite + } else if has_api_framework && !has_web_framework { + ProjectType::ApiService + } else if has_web_framework { + ProjectType::WebApplication + } 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, + } + }); + + if has_lib_indicators { + ProjectType::Library + } else { + ProjectType::Unknown + } + } else { + ProjectType::Unknown + } +} \ No newline at end of file diff --git a/src/analyzer/context/tech_specific.rs b/src/analyzer/context/tech_specific.rs new file mode 100644 index 00000000..7b69b2e9 --- /dev/null +++ b/src/analyzer/context/tech_specific.rs @@ -0,0 +1,120 @@ +use crate::analyzer::{DetectedTechnology, EntryPoint, Port, Protocol}; +use crate::error::Result; +use std::collections::HashSet; +use std::path::{Path}; + +/// Analyzes technology-specific configurations +pub(crate) fn analyze_technology_specifics( + technology: &DetectedTechnology, + root: &Path, + entry_points: &mut Vec, + ports: &mut HashSet, +) -> Result<()> { + match technology.name.as_str() { + "Next.js" => { + // Next.js typically runs on port 3000 + ports.insert(Port { + number: 3000, + protocol: Protocol::Http, + description: Some("Next.js development server".to_string()), + }); + + // Look for pages directory + let pages_dir = root.join("pages"); + if pages_dir.is_dir() { + entry_points.push(EntryPoint { + file: pages_dir, + function: None, + command: Some("npm run dev".to_string()), + }); + } + } + "Express" | "Fastify" | "Koa" | "Hono" | "Elysia" => { + // Common Node.js web framework ports + ports.insert(Port { + number: 3000, + protocol: Protocol::Http, + description: Some(format!("{} server", technology.name)), + }); + } + "Encore" => { + // Encore development server typically runs on port 4000 + ports.insert(Port { + number: 4000, + protocol: Protocol::Http, + description: Some("Encore development server".to_string()), + }); + } + "Astro" => { + // Astro development server typically runs on port 3000 or 4321 + ports.insert(Port { + number: 4321, + protocol: Protocol::Http, + description: Some("Astro development server".to_string()), + }); + } + "SvelteKit" => { + // SvelteKit development server typically runs on port 5173 + ports.insert(Port { + number: 5173, + protocol: Protocol::Http, + description: Some("SvelteKit development server".to_string()), + }); + } + "Nuxt.js" => { + // Nuxt.js development server typically runs on port 3000 + ports.insert(Port { + number: 3000, + protocol: Protocol::Http, + description: Some("Nuxt.js development server".to_string()), + }); + } + "Tanstack Start" => { + // Modern React framework typically runs on port 3000 + ports.insert(Port { + number: 3000, + protocol: Protocol::Http, + description: Some(format!("{} development server", technology.name)), + }); + } + "React Router v7" => { + // React Router v7 development server typically runs on port 5173 + ports.insert(Port { + number: 5173, + protocol: Protocol::Http, + description: Some("React Router v7 development server".to_string()), + }); + } + "Django" => { + ports.insert(Port { + number: 8000, + protocol: Protocol::Http, + description: Some("Django development server".to_string()), + }); + } + "Flask" | "FastAPI" => { + ports.insert(Port { + number: 5000, + protocol: Protocol::Http, + description: Some(format!("{} server", technology.name)), + }); + } + "Spring Boot" => { + ports.insert(Port { + number: 8080, + protocol: Protocol::Http, + description: Some("Spring Boot server".to_string()), + }); + } + "Actix Web" | "Rocket" => { + ports.insert(Port { + number: 8080, + protocol: Protocol::Http, + description: Some(format!("{} server", technology.name)), + }); + } + _ => {} + } + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/dependency_parser.rs b/src/analyzer/dependency_parser.rs index 5db1bdc3..4be837c7 100644 --- a/src/analyzer/dependency_parser.rs +++ b/src/analyzer/dependency_parser.rs @@ -1027,12 +1027,12 @@ impl DependencyParser { let trimmed = line.trim(); // Look for dependency declarations - if (trimmed.starts_with("implementation ") || + if trimmed.starts_with("implementation ") || trimmed.starts_with("compile ") || trimmed.starts_with("api ") || trimmed.starts_with("runtimeOnly ") || trimmed.starts_with("testImplementation ") || - trimmed.starts_with("testCompile ")) { + trimmed.starts_with("testCompile ") { if let Some(dep_str) = extract_gradle_dependency(trimmed) { let parts: Vec<&str> = dep_str.split(':').collect(); diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index e89a4290..95aeca25 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -16,12 +16,12 @@ pub mod dependency_parser; pub mod framework_detector; pub mod frameworks; pub mod language_detector; -pub mod project_context; +pub mod context; pub mod vulnerability_checker; pub mod security_analyzer; pub mod security; pub mod tool_installer; -pub mod monorepo_detector; +pub mod monorepo; pub mod docker_analyzer; pub mod display; @@ -44,7 +44,7 @@ pub use security::{ pub use security::config::SecurityConfigPreset; // Re-export monorepo analysis types -pub use monorepo_detector::{ +pub use monorepo::{ MonorepoDetectionConfig, analyze_monorepo, analyze_monorepo_with_config }; @@ -389,7 +389,7 @@ pub fn analyze_project_with_config(path: &Path, config: &AnalysisConfig) -> Resu let languages = language_detector::detect_languages(&files, config)?; let frameworks = framework_detector::detect_frameworks(&project_root, &languages, config)?; let dependencies = dependency_parser::parse_dependencies(&project_root, &languages, config)?; - let context = project_context::analyze_context(&project_root, &languages, &frameworks, config)?; + let context = context::analyze_context(&project_root, &languages, &frameworks, config)?; // Analyze Docker infrastructure let docker_analysis = analyze_docker_infrastructure(&project_root).ok(); diff --git a/src/analyzer/monorepo/analysis.rs b/src/analyzer/monorepo/analysis.rs new file mode 100644 index 00000000..f13bd23a --- /dev/null +++ b/src/analyzer/monorepo/analysis.rs @@ -0,0 +1,115 @@ +use crate::analyzer::{ + analyze_project_with_config, AnalysisConfig, AnalysisMetadata, MonorepoAnalysis, ProjectInfo, +}; +use crate::common::file_utils; +use crate::error::Result; +use chrono::Utc; +use std::path::{Path, PathBuf}; + +use super::config::MonorepoDetectionConfig; +use super::detection::{detect_potential_projects, determine_if_monorepo}; +use super::helpers::calculate_overall_confidence; +use super::project_info::{determine_project_category, extract_project_name}; +use super::summary::generate_technology_summary; + +/// Detects if a path contains a monorepo and analyzes all projects within it +pub fn analyze_monorepo(path: &Path) -> Result { + analyze_monorepo_with_config(path, &MonorepoDetectionConfig::default(), &AnalysisConfig::default()) +} + +/// Analyzes a monorepo with custom configuration +pub fn analyze_monorepo_with_config( + path: &Path, + monorepo_config: &MonorepoDetectionConfig, + analysis_config: &AnalysisConfig, +) -> Result { + let start_time = std::time::Instant::now(); + let root_path = file_utils::validate_project_path(path)?; + + log::info!("Starting monorepo analysis of: {}", root_path.display()); + + // Detect potential projects within the path + let potential_projects = detect_potential_projects(&root_path, monorepo_config)?; + + log::debug!("Found {} potential projects", potential_projects.len()); + + // Determine if this is actually a monorepo or just a single project + let is_monorepo = determine_if_monorepo(&root_path, &potential_projects, monorepo_config)?; + + let mut projects = Vec::new(); + + if is_monorepo && potential_projects.len() > 1 { + // Analyze each project separately + for project_path in potential_projects { + if let Ok(project_info) = analyze_individual_project(&root_path, &project_path, analysis_config) { + projects.push(project_info); + } + } + + // If we didn't find multiple valid projects, treat as single project + if projects.len() <= 1 { + log::info!("Detected potential monorepo but only found {} valid project(s), treating as single project", projects.len()); + projects.clear(); + let single_analysis = analyze_project_with_config(&root_path, analysis_config)?; + projects.push(ProjectInfo { + path: PathBuf::from("."), + name: extract_project_name(&root_path, &single_analysis), + project_category: determine_project_category(&single_analysis, &root_path), + analysis: single_analysis, + }); + } + } else { + // Single project analysis + let single_analysis = analyze_project_with_config(&root_path, analysis_config)?; + projects.push(ProjectInfo { + path: PathBuf::from("."), + name: extract_project_name(&root_path, &single_analysis), + project_category: determine_project_category(&single_analysis, &root_path), + analysis: single_analysis, + }); + } + + // Generate technology summary + let technology_summary = generate_technology_summary(&projects); + + let duration = start_time.elapsed(); + let metadata = AnalysisMetadata { + timestamp: Utc::now().to_rfc3339(), + analyzer_version: env!("CARGO_PKG_VERSION").to_string(), + analysis_duration_ms: duration.as_millis() as u64, + files_analyzed: projects.iter().map(|p| p.analysis.analysis_metadata.files_analyzed).sum(), + confidence_score: calculate_overall_confidence(&projects), + }; + + Ok(MonorepoAnalysis { + root_path, + is_monorepo: projects.len() > 1, + projects, + metadata, + technology_summary, + }) +} + +/// Analyzes an individual project within a monorepo +fn analyze_individual_project( + root_path: &Path, + project_path: &Path, + config: &AnalysisConfig, +) -> Result { + log::debug!("Analyzing individual project: {}", project_path.display()); + + let analysis = analyze_project_with_config(project_path, config)?; + let relative_path = project_path.strip_prefix(root_path) + .unwrap_or(project_path) + .to_path_buf(); + + let name = extract_project_name(project_path, &analysis); + let category = determine_project_category(&analysis, project_path); + + Ok(ProjectInfo { + path: relative_path, + name, + project_category: category, + analysis, + }) +} \ No newline at end of file diff --git a/src/analyzer/monorepo/config.rs b/src/analyzer/monorepo/config.rs new file mode 100644 index 00000000..00294ea5 --- /dev/null +++ b/src/analyzer/monorepo/config.rs @@ -0,0 +1,39 @@ +/// Configuration for monorepo detection +#[derive(Debug, Clone)] +pub struct MonorepoDetectionConfig { + /// Maximum depth to search for projects + pub max_depth: usize, + /// Minimum confidence threshold for considering a directory as a project + pub min_project_confidence: f32, + /// Whether to analyze subdirectories that might be projects + pub deep_scan: bool, + /// Patterns to exclude from project detection + pub exclude_patterns: Vec, +} + +impl Default for MonorepoDetectionConfig { + fn default() -> Self { + Self { + max_depth: 3, + min_project_confidence: 0.6, + deep_scan: true, + exclude_patterns: vec![ + "node_modules".to_string(), + ".git".to_string(), + "target".to_string(), + "build".to_string(), + "dist".to_string(), + ".next".to_string(), + "__pycache__".to_string(), + "vendor".to_string(), + ".venv".to_string(), + "venv".to_string(), + ".env".to_string(), + "coverage".to_string(), + "docs".to_string(), + "tmp".to_string(), + "temp".to_string(), + ], + } + } +} \ No newline at end of file diff --git a/src/analyzer/monorepo/detection.rs b/src/analyzer/monorepo/detection.rs new file mode 100644 index 00000000..43c450d2 --- /dev/null +++ b/src/analyzer/monorepo/detection.rs @@ -0,0 +1,227 @@ +use super::config::MonorepoDetectionConfig; +use crate::error::Result; +use serde_json::Value as JsonValue; +use std::path::{Path, PathBuf}; + +/// Detects potential project directories within a given path +pub(crate) fn detect_potential_projects( + root_path: &Path, + config: &MonorepoDetectionConfig, +) -> Result> { + let mut potential_projects = Vec::new(); + + // Check if root itself is a project + if is_project_directory(root_path)? { + potential_projects.push(root_path.to_path_buf()); + } + + if config.deep_scan { + // Recursively check subdirectories + scan_for_projects(root_path, root_path, &mut potential_projects, 0, config)?; + } + + // Remove duplicates and sort by path depth (shallower first) + potential_projects.sort_by_key(|p| p.components().count()); + potential_projects.dedup(); + + // Filter out nested projects (prefer parent projects) + filter_nested_projects(potential_projects) +} + +/// Recursively scans for project directories +fn scan_for_projects( + root_path: &Path, + current_path: &Path, + projects: &mut Vec, + depth: usize, + config: &MonorepoDetectionConfig, +) -> Result<()> { + if depth >= config.max_depth { + return Ok(()); + } + + if let Ok(entries) = std::fs::read_dir(current_path) { + for entry in entries.flatten() { + if !entry.file_type()?.is_dir() { + continue; + } + + let dir_name = entry.file_name().to_string_lossy().to_string(); + let dir_path = entry.path(); + + // Skip excluded patterns + if should_exclude_directory(&dir_name, config) { + continue; + } + + // Check if this directory looks like a project + if is_project_directory(&dir_path)? { + projects.push(dir_path.clone()); + } + + // Continue scanning subdirectories + scan_for_projects(root_path, &dir_path, projects, depth + 1, config)?; + } + } + + Ok(()) +} + +/// Determines if a directory should be excluded from scanning +fn should_exclude_directory(dir_name: &str, config: &MonorepoDetectionConfig) -> bool { + // Skip hidden directories + if dir_name.starts_with('.') { + return true; + } + + // Skip excluded patterns + config.exclude_patterns.iter().any(|pattern| dir_name == pattern) +} + +/// Checks if a directory appears to be a project directory +fn is_project_directory(path: &Path) -> Result { + // Common project indicator files + let project_indicators = [ + // JavaScript/TypeScript + "package.json", + // Rust + "Cargo.toml", + // Python + "requirements.txt", "pyproject.toml", "Pipfile", "setup.py", + // Go + "go.mod", + // Java/Kotlin + "pom.xml", "build.gradle", "build.gradle.kts", + // .NET + "*.csproj", "*.fsproj", "*.vbproj", + // Ruby + "Gemfile", + // PHP + "composer.json", + // Docker + "Dockerfile", + ]; + + // Check for manifest files + for indicator in &project_indicators { + if indicator.contains('*') { + // Handle glob patterns + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + let pattern = indicator.replace('*', ""); + if file_name.ends_with(&pattern) { + return Ok(true); + } + } + } + } + } else { + if path.join(indicator).exists() { + return Ok(true); + } + } + } + + // Check for common source directories with code + let source_dirs = ["src", "lib", "app", "pages", "components"]; + for src_dir in &source_dirs { + let src_path = path.join(src_dir); + if src_path.is_dir() && directory_contains_code(&src_path)? { + return Ok(true); + } + } + + Ok(false) +} + +/// Checks if a directory contains source code files +fn directory_contains_code(path: &Path) -> Result { + let code_extensions = ["js", "ts", "jsx", "tsx", "py", "rs", "go", "java", "kt", "cs", "rb", "php"]; + + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if let Some(extension) = entry.path().extension() { + if let Some(ext_str) = extension.to_str() { + if code_extensions.contains(&ext_str) { + return Ok(true); + } + } + } + + // Recursively check subdirectories (limited depth) + if entry.file_type()?.is_dir() { + if directory_contains_code(&entry.path())? { + return Ok(true); + } + } + } + } + + Ok(false) +} + +/// Filters out nested projects, keeping only top-level ones +fn filter_nested_projects(mut projects: Vec) -> Result> { + projects.sort_by_key(|p| p.components().count()); + + let mut filtered = Vec::new(); + + for project in projects { + let is_nested = filtered.iter().any(|parent: &PathBuf| { + project.starts_with(parent) && project != *parent + }); + + if !is_nested { + filtered.push(project); + } + } + + Ok(filtered) +} + +/// Determines if the detected projects constitute a monorepo +pub(crate) fn determine_if_monorepo( + root_path: &Path, + potential_projects: &[PathBuf], + _config: &MonorepoDetectionConfig, +) -> Result { + // If we have multiple project directories, likely a monorepo + if potential_projects.len() > 1 { + return Ok(true); + } + + // Check for common monorepo indicators + let monorepo_indicators = [ + "lerna.json", // Lerna + "nx.json", // Nx + "rush.json", // Rush + "pnpm-workspace.yaml", // pnpm workspaces + "yarn.lock", // Yarn workspaces (need to check package.json) + "packages", // Common packages directory + "apps", // Common apps directory + "services", // Common services directory + "libs", // Common libs directory + ]; + + for indicator in &monorepo_indicators { + if root_path.join(indicator).exists() { + return Ok(true); + } + } + + // Check package.json for workspace configuration + let package_json_path = root_path.join("package.json"); + if package_json_path.exists() { + if let Ok(content) = std::fs::read_to_string(&package_json_path) { + if let Ok(package_json) = serde_json::from_str::(&content) { + // Check for workspaces + if package_json.get("workspaces").is_some() { + return Ok(true); + } + } + } + } + + Ok(false) +} \ No newline at end of file diff --git a/src/analyzer/monorepo/helpers.rs b/src/analyzer/monorepo/helpers.rs new file mode 100644 index 00000000..97178a4f --- /dev/null +++ b/src/analyzer/monorepo/helpers.rs @@ -0,0 +1,14 @@ +use crate::analyzer::ProjectInfo; + +/// Calculates overall confidence score across all projects +pub(crate) fn calculate_overall_confidence(projects: &[ProjectInfo]) -> f32 { + if projects.is_empty() { + return 0.0; + } + + let total_confidence: f32 = projects.iter() + .map(|p| p.analysis.analysis_metadata.confidence_score) + .sum(); + + total_confidence / projects.len() as f32 +} \ No newline at end of file diff --git a/src/analyzer/monorepo/mod.rs b/src/analyzer/monorepo/mod.rs new file mode 100644 index 00000000..c580b331 --- /dev/null +++ b/src/analyzer/monorepo/mod.rs @@ -0,0 +1,9 @@ +pub mod analysis; +pub mod config; +mod detection; +mod helpers; +mod project_info; +mod summary; + +pub use analysis::{analyze_monorepo, analyze_monorepo_with_config}; +pub use config::MonorepoDetectionConfig; \ No newline at end of file diff --git a/src/analyzer/monorepo/project_info.rs b/src/analyzer/monorepo/project_info.rs new file mode 100644 index 00000000..7350bd2c --- /dev/null +++ b/src/analyzer/monorepo/project_info.rs @@ -0,0 +1,122 @@ +use crate::analyzer::{ProjectAnalysis, ProjectCategory}; +use serde_json::Value as JsonValue; +use std::path::Path; + +/// Extracts a meaningful project name from path and analysis +pub(crate) fn extract_project_name(project_path: &Path, _analysis: &ProjectAnalysis) -> String { + // Try to get name from package.json + let package_json_path = project_path.join("package.json"); + if package_json_path.exists() { + if let Ok(content) = std::fs::read_to_string(&package_json_path) { + if let Ok(package_json) = serde_json::from_str::(&content) { + if let Some(name) = package_json.get("name").and_then(|n| n.as_str()) { + return name.to_string(); + } + } + } + } + + // Try to get name from Cargo.toml + let cargo_toml_path = project_path.join("Cargo.toml"); + 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") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) { + return name.to_string(); + } + } + } + } + + // Try to get name from pyproject.toml + let pyproject_toml_path = project_path.join("pyproject.toml"); + 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") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) { + return name.to_string(); + } 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()) { + return name.to_string(); + } + } + } + } + + // Fall back to directory 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() + .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("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), + _ => None, + }; + + // If we found a category from the directory name, return it + if let Some(category) = category_from_name { + return category; + } + + // 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" + ) + }); + + 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" + ) + }); + + let has_api_tech = analysis.technologies.iter().any(|t| { + 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) + }); + + if has_frontend_tech && !has_backend_tech { + ProjectCategory::Frontend + } else if has_backend_tech && !has_frontend_tech { + ProjectCategory::Backend + } else if has_api_tech || (has_backend_tech && has_database) { + ProjectCategory::Api + } else if matches!(analysis.project_type, crate::analyzer::ProjectType::Library) { + ProjectCategory::Library + } else if matches!(analysis.project_type, crate::analyzer::ProjectType::CliTool) { + ProjectCategory::Tool + } else { + ProjectCategory::Unknown + } +} \ No newline at end of file diff --git a/src/analyzer/monorepo/summary.rs b/src/analyzer/monorepo/summary.rs new file mode 100644 index 00000000..896b8d1e --- /dev/null +++ b/src/analyzer/monorepo/summary.rs @@ -0,0 +1,62 @@ +use crate::analyzer::{ArchitecturePattern, ProjectCategory, ProjectInfo, TechnologySummary}; +use std::collections::HashSet; + +/// Generates a summary of technologies across all projects +pub(crate) fn generate_technology_summary(projects: &[ProjectInfo]) -> TechnologySummary { + let mut all_languages = HashSet::new(); + let mut all_frameworks = HashSet::new(); + let mut all_databases = HashSet::new(); + + for project in projects { + // Collect languages + for lang in &project.analysis.languages { + all_languages.insert(lang.name.clone()); + } + + // Collect technologies + for tech in &project.analysis.technologies { + match tech.category { + crate::analyzer::TechnologyCategory::FrontendFramework | + crate::analyzer::TechnologyCategory::BackendFramework | + crate::analyzer::TechnologyCategory::MetaFramework => { + all_frameworks.insert(tech.name.clone()); + } + crate::analyzer::TechnologyCategory::Database => { + all_databases.insert(tech.name.clone()); + } + _ => {} + } + } + } + + let architecture_pattern = determine_architecture_pattern(projects); + + TechnologySummary { + languages: all_languages.into_iter().collect(), + frameworks: all_frameworks.into_iter().collect(), + databases: all_databases.into_iter().collect(), + total_projects: projects.len(), + architecture_pattern, + } +} + +/// Determines the overall architecture pattern +fn determine_architecture_pattern(projects: &[ProjectInfo]) -> ArchitecturePattern { + if projects.len() == 1 { + return ArchitecturePattern::Monolithic; + } + + let has_frontend = projects.iter().any(|p| p.project_category == ProjectCategory::Frontend); + let has_backend = projects.iter().any(|p| matches!(p.project_category, ProjectCategory::Backend | ProjectCategory::Api)); + let service_count = projects.iter().filter(|p| p.project_category == ProjectCategory::Service).count(); + + if service_count >= 2 { + ArchitecturePattern::Microservices + } else if has_frontend && has_backend { + ArchitecturePattern::Fullstack + } else if projects.iter().all(|p| p.project_category == ProjectCategory::Api) { + ArchitecturePattern::ApiFirst + } else { + ArchitecturePattern::Mixed + } +} \ No newline at end of file diff --git a/src/analyzer/monorepo_detector.rs b/src/analyzer/monorepo_detector.rs deleted file mode 100644 index 3ad6b96a..00000000 --- a/src/analyzer/monorepo_detector.rs +++ /dev/null @@ -1,615 +0,0 @@ -use crate::analyzer::{ - AnalysisConfig, ProjectInfo, ProjectCategory, MonorepoAnalysis, TechnologySummary, - ArchitecturePattern, analyze_project_with_config, ProjectAnalysis, AnalysisMetadata -}; -use crate::error::Result; -use crate::common::file_utils; -use std::path::{Path, PathBuf}; -use std::collections::HashSet; -use serde_json::Value as JsonValue; -use chrono::Utc; - -/// Configuration for monorepo detection -#[derive(Debug, Clone)] -pub struct MonorepoDetectionConfig { - /// Maximum depth to search for projects - pub max_depth: usize, - /// Minimum confidence threshold for considering a directory as a project - pub min_project_confidence: f32, - /// Whether to analyze subdirectories that might be projects - pub deep_scan: bool, - /// Patterns to exclude from project detection - pub exclude_patterns: Vec, -} - -impl Default for MonorepoDetectionConfig { - fn default() -> Self { - Self { - max_depth: 3, - min_project_confidence: 0.6, - deep_scan: true, - exclude_patterns: vec![ - "node_modules".to_string(), - ".git".to_string(), - "target".to_string(), - "build".to_string(), - "dist".to_string(), - ".next".to_string(), - "__pycache__".to_string(), - "vendor".to_string(), - ".venv".to_string(), - "venv".to_string(), - ".env".to_string(), - "coverage".to_string(), - "docs".to_string(), - "tmp".to_string(), - "temp".to_string(), - ], - } - } -} - -/// Detects if a path contains a monorepo and analyzes all projects within it -pub fn analyze_monorepo(path: &Path) -> Result { - analyze_monorepo_with_config(path, &MonorepoDetectionConfig::default(), &AnalysisConfig::default()) -} - -/// Analyzes a monorepo with custom configuration -pub fn analyze_monorepo_with_config( - path: &Path, - monorepo_config: &MonorepoDetectionConfig, - analysis_config: &AnalysisConfig, -) -> Result { - let start_time = std::time::Instant::now(); - let root_path = file_utils::validate_project_path(path)?; - - log::info!("Starting monorepo analysis of: {}", root_path.display()); - - // Detect potential projects within the path - let potential_projects = detect_potential_projects(&root_path, monorepo_config)?; - - log::debug!("Found {} potential projects", potential_projects.len()); - - // Determine if this is actually a monorepo or just a single project - let is_monorepo = determine_if_monorepo(&root_path, &potential_projects, monorepo_config)?; - - let mut projects = Vec::new(); - - if is_monorepo && potential_projects.len() > 1 { - // Analyze each project separately - for project_path in potential_projects { - if let Ok(project_info) = analyze_individual_project(&root_path, &project_path, analysis_config) { - projects.push(project_info); - } - } - - // If we didn't find multiple valid projects, treat as single project - if projects.len() <= 1 { - log::info!("Detected potential monorepo but only found {} valid project(s), treating as single project", projects.len()); - projects.clear(); - let single_analysis = analyze_project_with_config(&root_path, analysis_config)?; - projects.push(ProjectInfo { - path: PathBuf::from("."), - name: extract_project_name(&root_path, &single_analysis), - project_category: determine_project_category(&single_analysis, &root_path), - analysis: single_analysis, - }); - } - } else { - // Single project analysis - let single_analysis = analyze_project_with_config(&root_path, analysis_config)?; - projects.push(ProjectInfo { - path: PathBuf::from("."), - name: extract_project_name(&root_path, &single_analysis), - project_category: determine_project_category(&single_analysis, &root_path), - analysis: single_analysis, - }); - } - - // Generate technology summary - let technology_summary = generate_technology_summary(&projects); - - let duration = start_time.elapsed(); - let metadata = AnalysisMetadata { - timestamp: Utc::now().to_rfc3339(), - analyzer_version: env!("CARGO_PKG_VERSION").to_string(), - analysis_duration_ms: duration.as_millis() as u64, - files_analyzed: projects.iter().map(|p| p.analysis.analysis_metadata.files_analyzed).sum(), - confidence_score: calculate_overall_confidence(&projects), - }; - - Ok(MonorepoAnalysis { - root_path, - is_monorepo: projects.len() > 1, - projects, - metadata, - technology_summary, - }) -} - -/// Detects potential project directories within a given path -fn detect_potential_projects( - root_path: &Path, - config: &MonorepoDetectionConfig -) -> Result> { - let mut potential_projects = Vec::new(); - - // Check if root itself is a project - if is_project_directory(root_path)? { - potential_projects.push(root_path.to_path_buf()); - } - - if config.deep_scan { - // Recursively check subdirectories - scan_for_projects(root_path, root_path, &mut potential_projects, 0, config)?; - } - - // Remove duplicates and sort by path depth (shallower first) - potential_projects.sort_by_key(|p| p.components().count()); - potential_projects.dedup(); - - // Filter out nested projects (prefer parent projects) - filter_nested_projects(potential_projects) -} - -/// Recursively scans for project directories -fn scan_for_projects( - root_path: &Path, - current_path: &Path, - projects: &mut Vec, - depth: usize, - config: &MonorepoDetectionConfig, -) -> Result<()> { - if depth >= config.max_depth { - return Ok(()); - } - - if let Ok(entries) = std::fs::read_dir(current_path) { - for entry in entries.flatten() { - if !entry.file_type()?.is_dir() { - continue; - } - - let dir_name = entry.file_name().to_string_lossy().to_string(); - let dir_path = entry.path(); - - // Skip excluded patterns - if should_exclude_directory(&dir_name, config) { - continue; - } - - // Check if this directory looks like a project - if is_project_directory(&dir_path)? { - projects.push(dir_path.clone()); - } - - // Continue scanning subdirectories - scan_for_projects(root_path, &dir_path, projects, depth + 1, config)?; - } - } - - Ok(()) -} - -/// Determines if a directory should be excluded from scanning -fn should_exclude_directory(dir_name: &str, config: &MonorepoDetectionConfig) -> bool { - // Skip hidden directories - if dir_name.starts_with('.') { - return true; - } - - // Skip excluded patterns - config.exclude_patterns.iter().any(|pattern| dir_name == pattern) -} - -/// Checks if a directory appears to be a project directory -fn is_project_directory(path: &Path) -> Result { - // Common project indicator files - let project_indicators = [ - // JavaScript/TypeScript - "package.json", - // Rust - "Cargo.toml", - // Python - "requirements.txt", "pyproject.toml", "Pipfile", "setup.py", - // Go - "go.mod", - // Java/Kotlin - "pom.xml", "build.gradle", "build.gradle.kts", - // .NET - "*.csproj", "*.fsproj", "*.vbproj", - // Ruby - "Gemfile", - // PHP - "composer.json", - // Docker - "Dockerfile", - ]; - - // Check for manifest files - for indicator in &project_indicators { - if indicator.contains('*') { - // Handle glob patterns - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.flatten() { - if let Some(file_name) = entry.file_name().to_str() { - let pattern = indicator.replace('*', ""); - if file_name.ends_with(&pattern) { - return Ok(true); - } - } - } - } - } else { - if path.join(indicator).exists() { - return Ok(true); - } - } - } - - // Check for common source directories with code - let source_dirs = ["src", "lib", "app", "pages", "components"]; - for src_dir in &source_dirs { - let src_path = path.join(src_dir); - if src_path.is_dir() && directory_contains_code(&src_path)? { - return Ok(true); - } - } - - Ok(false) -} - -/// Checks if a directory contains source code files -fn directory_contains_code(path: &Path) -> Result { - let code_extensions = ["js", "ts", "jsx", "tsx", "py", "rs", "go", "java", "kt", "cs", "rb", "php"]; - - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.flatten() { - if let Some(extension) = entry.path().extension() { - if let Some(ext_str) = extension.to_str() { - if code_extensions.contains(&ext_str) { - return Ok(true); - } - } - } - - // Recursively check subdirectories (limited depth) - if entry.file_type()?.is_dir() { - if directory_contains_code(&entry.path())? { - return Ok(true); - } - } - } - } - - Ok(false) -} - -/// Filters out nested projects, keeping only top-level ones -fn filter_nested_projects(mut projects: Vec) -> Result> { - projects.sort_by_key(|p| p.components().count()); - - let mut filtered = Vec::new(); - - for project in projects { - let is_nested = filtered.iter().any(|parent: &PathBuf| { - project.starts_with(parent) && project != *parent - }); - - if !is_nested { - filtered.push(project); - } - } - - Ok(filtered) -} - -/// Determines if the detected projects constitute a monorepo -fn determine_if_monorepo( - root_path: &Path, - potential_projects: &[PathBuf], - _config: &MonorepoDetectionConfig, -) -> Result { - // If we have multiple project directories, likely a monorepo - if potential_projects.len() > 1 { - return Ok(true); - } - - // Check for common monorepo indicators - let monorepo_indicators = [ - "lerna.json", // Lerna - "nx.json", // Nx - "rush.json", // Rush - "pnpm-workspace.yaml", // pnpm workspaces - "yarn.lock", // Yarn workspaces (need to check package.json) - "packages", // Common packages directory - "apps", // Common apps directory - "services", // Common services directory - "libs", // Common libs directory - ]; - - for indicator in &monorepo_indicators { - if root_path.join(indicator).exists() { - return Ok(true); - } - } - - // Check package.json for workspace configuration - let package_json_path = root_path.join("package.json"); - if package_json_path.exists() { - if let Ok(content) = std::fs::read_to_string(&package_json_path) { - if let Ok(package_json) = serde_json::from_str::(&content) { - // Check for workspaces - if package_json.get("workspaces").is_some() { - return Ok(true); - } - } - } - } - - Ok(false) -} - -/// Analyzes an individual project within a monorepo -fn analyze_individual_project( - root_path: &Path, - project_path: &Path, - config: &AnalysisConfig, -) -> Result { - log::debug!("Analyzing individual project: {}", project_path.display()); - - let analysis = analyze_project_with_config(project_path, config)?; - let relative_path = project_path.strip_prefix(root_path) - .unwrap_or(project_path) - .to_path_buf(); - - let name = extract_project_name(project_path, &analysis); - let category = determine_project_category(&analysis, project_path); - - Ok(ProjectInfo { - path: relative_path, - name, - project_category: category, - analysis, - }) -} - -/// Extracts a meaningful project name from path and analysis -fn extract_project_name(project_path: &Path, _analysis: &ProjectAnalysis) -> String { - // Try to get name from package.json - let package_json_path = project_path.join("package.json"); - if package_json_path.exists() { - if let Ok(content) = std::fs::read_to_string(&package_json_path) { - if let Ok(package_json) = serde_json::from_str::(&content) { - if let Some(name) = package_json.get("name").and_then(|n| n.as_str()) { - return name.to_string(); - } - } - } - } - - // Try to get name from Cargo.toml - let cargo_toml_path = project_path.join("Cargo.toml"); - 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") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) { - return name.to_string(); - } - } - } - } - - // Try to get name from pyproject.toml - let pyproject_toml_path = project_path.join("pyproject.toml"); - 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") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) { - return name.to_string(); - } 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()) { - return name.to_string(); - } - } - } - } - - // Fall back to directory 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 -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("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), - _ => None, - }; - - // If we found a category from the directory name, return it - if let Some(category) = category_from_name { - return category; - } - - // 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" - ) - }); - - 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" - ) - }); - - let has_api_tech = analysis.technologies.iter().any(|t| { - 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) - }); - - if has_frontend_tech && !has_backend_tech { - ProjectCategory::Frontend - } else if has_backend_tech && !has_frontend_tech { - ProjectCategory::Backend - } else if has_api_tech || (has_backend_tech && has_database) { - ProjectCategory::Api - } else if matches!(analysis.project_type, crate::analyzer::ProjectType::Library) { - ProjectCategory::Library - } else if matches!(analysis.project_type, crate::analyzer::ProjectType::CliTool) { - ProjectCategory::Tool - } else { - ProjectCategory::Unknown - } -} - -/// Generates a summary of technologies across all projects -fn generate_technology_summary(projects: &[ProjectInfo]) -> TechnologySummary { - let mut all_languages = HashSet::new(); - let mut all_frameworks = HashSet::new(); - let mut all_databases = HashSet::new(); - - for project in projects { - // Collect languages - for lang in &project.analysis.languages { - all_languages.insert(lang.name.clone()); - } - - // Collect technologies - for tech in &project.analysis.technologies { - match tech.category { - crate::analyzer::TechnologyCategory::FrontendFramework | - crate::analyzer::TechnologyCategory::BackendFramework | - crate::analyzer::TechnologyCategory::MetaFramework => { - all_frameworks.insert(tech.name.clone()); - } - crate::analyzer::TechnologyCategory::Database => { - all_databases.insert(tech.name.clone()); - } - _ => {} - } - } - } - - let architecture_pattern = determine_architecture_pattern(projects); - - TechnologySummary { - languages: all_languages.into_iter().collect(), - frameworks: all_frameworks.into_iter().collect(), - databases: all_databases.into_iter().collect(), - total_projects: projects.len(), - architecture_pattern, - } -} - -/// Determines the overall architecture pattern -fn determine_architecture_pattern(projects: &[ProjectInfo]) -> ArchitecturePattern { - if projects.len() == 1 { - return ArchitecturePattern::Monolithic; - } - - let has_frontend = projects.iter().any(|p| p.project_category == ProjectCategory::Frontend); - let has_backend = projects.iter().any(|p| matches!(p.project_category, ProjectCategory::Backend | ProjectCategory::Api)); - let service_count = projects.iter().filter(|p| p.project_category == ProjectCategory::Service).count(); - - if service_count >= 2 { - ArchitecturePattern::Microservices - } else if has_frontend && has_backend { - ArchitecturePattern::Fullstack - } else if projects.iter().all(|p| p.project_category == ProjectCategory::Api) { - ArchitecturePattern::ApiFirst - } else { - ArchitecturePattern::Mixed - } -} - -/// Calculates overall confidence score across all projects -fn calculate_overall_confidence(projects: &[ProjectInfo]) -> f32 { - if projects.is_empty() { - return 0.0; - } - - let total_confidence: f32 = projects.iter() - .map(|p| p.analysis.analysis_metadata.confidence_score) - .sum(); - - total_confidence / projects.len() as f32 -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - use std::fs; - - #[test] - fn test_single_project_detection() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create a simple Node.js project - fs::write(root.join("package.json"), r#"{"name": "test-app"}"#).unwrap(); - fs::write(root.join("index.js"), "console.log('hello');").unwrap(); - - let analysis = analyze_monorepo(root).unwrap(); - - assert!(!analysis.is_monorepo); - assert_eq!(analysis.projects.len(), 1); - assert_eq!(analysis.projects[0].name, "test-app"); - } - - #[test] - fn test_monorepo_detection() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create frontend project - let frontend_dir = root.join("frontend"); - fs::create_dir_all(&frontend_dir).unwrap(); - fs::write(frontend_dir.join("package.json"), r#"{"name": "frontend-app", "dependencies": {"react": "^18.0.0"}}"#).unwrap(); - - // Create backend project - let backend_dir = root.join("backend"); - fs::create_dir_all(&backend_dir).unwrap(); - fs::write(backend_dir.join("package.json"), r#"{"name": "backend-api", "dependencies": {"express": "^4.18.0"}}"#).unwrap(); - - // Create root package.json with workspaces - fs::write(root.join("package.json"), r#"{"name": "monorepo", "workspaces": ["frontend", "backend"]}"#).unwrap(); - - let analysis = analyze_monorepo(root).unwrap(); - - assert!(analysis.is_monorepo); - assert_eq!(analysis.projects.len(), 2); - assert_eq!(analysis.technology_summary.architecture_pattern, ArchitecturePattern::Fullstack); - } -} \ No newline at end of file diff --git a/src/analyzer/project_context.rs b/src/analyzer/project_context.rs deleted file mode 100644 index 4f072592..00000000 --- a/src/analyzer/project_context.rs +++ /dev/null @@ -1,1870 +0,0 @@ -#[allow(unused_imports)] -use crate::analyzer::{AnalysisConfig, DetectedTechnology, DetectedLanguage, EntryPoint, EnvVar, Port, Protocol, ProjectType, BuildScript, TechnologyCategory, LibraryType}; -use crate::error::{Result, AnalysisError}; -use crate::common::file_utils::{read_file_safe, is_readable_file}; -use std::path::{Path, PathBuf}; -use std::collections::{HashSet, HashMap}; -use regex::Regex; -use serde_json::Value; - -/// Project context information -pub struct ProjectContext { - pub entry_points: Vec, - pub ports: Vec, - pub environment_variables: Vec, - pub project_type: ProjectType, - pub build_scripts: Vec, -} - -/// Helper function to create a regex with proper error handling -fn create_regex(pattern: &str) -> Result { - Regex::new(pattern) - .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex pattern '{}': {}", pattern, e)).into()) -} - -/// Analyzes project context including entry points, ports, and environment variables -pub fn analyze_context( - project_root: &Path, - languages: &[DetectedLanguage], - technologies: &[DetectedTechnology], - config: &AnalysisConfig, -) -> Result { - log::info!("Analyzing project context"); - - let mut entry_points = Vec::new(); - let mut ports = HashSet::new(); - let mut env_vars = HashMap::new(); - let mut build_scripts = Vec::new(); - - // Analyze based on detected languages - for language in languages { - match language.name.as_str() { - "JavaScript" | "TypeScript" => { - analyze_node_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; - } - "Python" => { - analyze_python_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; - } - "Rust" => { - analyze_rust_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; - } - "Go" => { - analyze_go_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; - } - "Java" | "Kotlin" => { - analyze_jvm_project(project_root, &mut entry_points, &mut ports, &mut env_vars, &mut build_scripts, config)?; - } - _ => {} - } - } - - // Analyze common configuration files - analyze_docker_files(project_root, &mut ports, &mut env_vars)?; - analyze_env_files(project_root, &mut env_vars)?; - analyze_makefile(project_root, &mut build_scripts)?; - - // Technology-specific analysis - for technology in technologies { - analyze_technology_specifics(technology, project_root, &mut entry_points, &mut ports)?; - } - - // Detect microservices structure - let microservices = detect_microservices_structure(project_root)?; - - // Determine project type - let ports_vec: Vec = ports.iter().cloned().collect(); - let project_type = determine_project_type_with_structure( - languages, - technologies, - &entry_points, - &ports_vec, - µservices - ); - - // Convert collections to vectors - let ports: Vec = ports.into_iter().collect(); - let environment_variables: Vec = env_vars.into_iter() - .map(|(name, (default, required, desc))| EnvVar { - name, - default_value: default, - required, - description: desc, - }) - .collect(); - - Ok(ProjectContext { - entry_points, - ports, - environment_variables, - project_type, - build_scripts, - }) -} - -/// Represents a detected microservice within the project -#[derive(Debug)] -struct MicroserviceInfo { - name: String, - has_db: bool, - has_api: bool, -} - -/// Detects microservice structure based on directory patterns -fn detect_microservices_structure(project_root: &Path) -> Result> { - let mut microservices = Vec::new(); - - // Common patterns for microservice directories - let service_indicators = ["api", "service", "encore.service.ts", "main.ts", "main.go", "main.py"]; - let db_indicators = ["db", "database", "migrations", "schema", "models"]; - - // Check root-level directories - if let Ok(entries) = std::fs::read_dir(project_root) { - for entry in entries.flatten() { - if entry.file_type()?.is_dir() { - let dir_name = entry.file_name().to_string_lossy().to_string(); - let dir_path = entry.path(); - - // Skip common non-service directories - if dir_name.starts_with('.') || - ["node_modules", "target", "dist", "build", "__pycache__", "vendor"].contains(&dir_name.as_str()) { - continue; - } - - // Check if this directory looks like a service - let mut has_api = false; - let mut has_db = false; - - if let Ok(sub_entries) = std::fs::read_dir(&dir_path) { - for sub_entry in sub_entries.flatten() { - let sub_name = sub_entry.file_name().to_string_lossy().to_string(); - - // Check for API indicators - if service_indicators.iter().any(|&ind| sub_name.contains(ind)) { - has_api = true; - } - - // Check for DB indicators - if db_indicators.iter().any(|&ind| sub_name.contains(ind)) { - has_db = true; - } - } - } - - // If it has service characteristics, add it as a microservice - if has_api || has_db { - microservices.push(MicroserviceInfo { - name: dir_name, - has_db, - has_api, - }); - } - } - } - } - - Ok(microservices) -} - -/// Enhanced project type determination including microservice structure analysis -fn determine_project_type_with_structure( - languages: &[DetectedLanguage], - technologies: &[DetectedTechnology], - entry_points: &[EntryPoint], - ports: &[Port], - microservices: &[MicroserviceInfo], -) -> ProjectType { - // If we have multiple services with databases, it's likely a microservice architecture - let services_with_db = microservices.iter().filter(|s| s.has_db).count(); - if services_with_db >= 2 || microservices.len() >= 3 { - return ProjectType::Microservice; - } - - // Fall back to original determination logic - determine_project_type(languages, technologies, entry_points, ports) -} - -/// Analyzes Node.js/JavaScript/TypeScript projects -fn analyze_node_project( - root: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - build_scripts: &mut Vec, - config: &AnalysisConfig, -) -> Result<()> { - let package_json_path = root.join("package.json"); - - if is_readable_file(&package_json_path) { - let content = read_file_safe(&package_json_path, config.max_file_size)?; - let package_json: Value = serde_json::from_str(&content)?; - - // Extract scripts - if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) { - for (name, command) in scripts { - if let Some(cmd) = command.as_str() { - build_scripts.push(BuildScript { - name: name.clone(), - command: cmd.to_string(), - description: get_script_description(name), - is_default: name == "start" || name == "dev", - }); - - // Look for ports in scripts - extract_ports_from_command(cmd, ports); - } - } - } - - // Find main entry point - if let Some(main) = package_json.get("main").and_then(|m| m.as_str()) { - entry_points.push(EntryPoint { - file: root.join(main), - function: None, - command: Some(format!("node {}", main)), - }); - } - - // Check common entry files - let common_entries = ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js", "main.ts"]; - for entry in &common_entries { - let path = root.join(entry); - if is_readable_file(&path) { - scan_js_file_for_context(&path, entry_points, ports, env_vars, config)?; - } - } - - // Check src directory - let src_dir = root.join("src"); - if src_dir.is_dir() { - for entry in &common_entries { - let path = src_dir.join(entry); - if is_readable_file(&path) { - scan_js_file_for_context(&path, entry_points, ports, env_vars, config)?; - } - } - } - } - - Ok(()) -} - -/// Scans JavaScript/TypeScript files for context information -fn scan_js_file_for_context( - path: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - config: &AnalysisConfig, -) -> Result<()> { - let content = read_file_safe(path, config.max_file_size)?; - - // Look for port assignments - let port_regex = Regex::new(r"(?:PORT|port)\s*[=:]\s*(?:process\.env\.PORT\s*\|\|\s*)?(\d{1,5})") - .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex: {}", e)))?; - for cap in port_regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("HTTP server port".to_string()), - }); - } - } - } - - // Look for app.listen() calls - let listen_regex = Regex::new(r"\.listen\s*\(\s*(\d{1,5})") - .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex: {}", e)))?; - for cap in listen_regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("Express/HTTP server".to_string()), - }); - } - } - } - - // Look for environment variable usage - let env_regex = Regex::new(r"process\.env\.([A-Z_][A-Z0-9_]*)") - .map_err(|e| AnalysisError::InvalidStructure(format!("Invalid regex: {}", e)))?; - for cap in env_regex.captures_iter(&content) { - if let Some(var_name) = cap.get(1) { - let name = var_name.as_str().to_string(); - if !name.starts_with("NODE_") { // Skip Node.js internal vars - env_vars.entry(name.clone()).or_insert((None, false, None)); - } - } - } - - // Look for Encore.dev imports and patterns - if content.contains("encore.dev") { - // Encore uses specific patterns for config and database - let encore_patterns = [ - (r#"secret\s*\(\s*['"]([A-Z_][A-Z0-9_]*)['"]"#, "Encore secret configuration"), - (r#"SQLDatabase\s*\(\s*['"](\w+)['"]"#, "Encore database"), - ]; - - for (pattern, description) in &encore_patterns { - let regex = Regex::new(pattern).unwrap_or_else(|_| Regex::new(r"").unwrap()); - for cap in regex.captures_iter(&content) { - if let Some(match_str) = cap.get(1) { - let name = match_str.as_str(); - if pattern.contains("secret") { - env_vars.entry(name.to_string()) - .or_insert((None, true, Some(description.to_string()))); - } - } - } - } - } - - // Check if this is a main entry point - if content.contains("createServer") || content.contains(".listen(") || - content.contains("app.listen") || content.contains("server.listen") || - content.contains("encore.dev") && content.contains("api.") { - entry_points.push(EntryPoint { - file: path.to_path_buf(), - function: Some("main".to_string()), - command: Some(format!("node {}", path.file_name().unwrap().to_string_lossy())), - }); - } - - Ok(()) -} - -/// Analyzes Python projects -fn analyze_python_project( - root: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - build_scripts: &mut Vec, - config: &AnalysisConfig, -) -> Result<()> { - // Check for common Python entry points - let common_entries = ["main.py", "app.py", "wsgi.py", "asgi.py", "manage.py", "run.py", "__main__.py"]; - - for entry in &common_entries { - let path = root.join(entry); - if is_readable_file(&path) { - scan_python_file_for_context(&path, entry_points, ports, env_vars, config)?; - } - } - - // Check setup.py for entry points - let setup_py = root.join("setup.py"); - if is_readable_file(&setup_py) { - let content = read_file_safe(&setup_py, config.max_file_size)?; - - // Look for console_scripts - let console_regex = create_regex(r#"console_scripts['"]\s*:\s*\[(.*?)\]"#)?; - if let Some(cap) = console_regex.captures(&content) { - if let Some(scripts) = cap.get(1) { - let script_regex = create_regex(r#"['"](\w+)\s*=\s*([\w\.]+):(\w+)"#)?; - for script_cap in script_regex.captures_iter(scripts.as_str()) { - if let (Some(name), Some(module), Some(func)) = - (script_cap.get(1), script_cap.get(2), script_cap.get(3)) { - entry_points.push(EntryPoint { - file: PathBuf::from(format!("{}.py", module.as_str().replace('.', "/"))), - function: Some(func.as_str().to_string()), - command: Some(name.as_str().to_string()), - }); - } - } - } - } - } - - // Check pyproject.toml for scripts - let pyproject = root.join("pyproject.toml"); - if is_readable_file(&pyproject) { - let content = read_file_safe(&pyproject, config.max_file_size)?; - if let Ok(toml_value) = toml::from_str::(&content) { - // Extract build scripts from poetry - if let Some(scripts) = toml_value.get("tool") - .and_then(|t| t.get("poetry")) - .and_then(|p| p.get("scripts")) - .and_then(|s| s.as_table()) { - for (name, cmd) in scripts { - if let Some(command) = cmd.as_str() { - build_scripts.push(BuildScript { - name: name.clone(), - command: command.to_string(), - description: None, - is_default: name == "start" || name == "run", - }); - } - } - } - } - } - - // Common Python build commands - build_scripts.push(BuildScript { - name: "install".to_string(), - command: "pip install -r requirements.txt".to_string(), - description: Some("Install dependencies".to_string()), - is_default: false, - }); - - Ok(()) -} - -/// Scans Python files for context information -fn scan_python_file_for_context( - path: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - config: &AnalysisConfig, -) -> Result<()> { - let content = read_file_safe(path, config.max_file_size)?; - - // Look for Flask/FastAPI/Django port configurations - let port_patterns = [ - r"port\s*=\s*(\d{1,5})", - r"PORT\s*=\s*(\d{1,5})", - r"\.run\s*\([^)]*port\s*=\s*(\d{1,5})", - r"uvicorn\.run\s*\([^)]*port\s*=\s*(\d{1,5})", - ]; - - for pattern in &port_patterns { - let regex = create_regex(pattern)?; - for cap in regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("Python web server".to_string()), - }); - } - } - } - } - - // Look for environment variable usage - let env_patterns = [ - r#"os\.environ\.get\s*\(\s*["']([A-Z_][A-Z0-9_]*)["']"#, - r#"os\.environ\s*\[\s*["']([A-Z_][A-Z0-9_]*)["']\s*\]"#, - r#"os\.getenv\s*\(\s*["']([A-Z_][A-Z0-9_]*)["']"#, - ]; - - for pattern in &env_patterns { - let regex = create_regex(pattern)?; - for cap in regex.captures_iter(&content) { - if let Some(var_name) = cap.get(1) { - let name = var_name.as_str().to_string(); - env_vars.entry(name.clone()).or_insert((None, false, None)); - } - } - } - - // Check if this is a main entry point - if content.contains("if __name__ == '__main__':") || - content.contains("if __name__ == \"__main__\":") { - entry_points.push(EntryPoint { - file: path.to_path_buf(), - function: Some("main".to_string()), - command: Some(format!("python {}", path.file_name().unwrap().to_string_lossy())), - }); - } - - Ok(()) -} - -/// Analyzes Rust projects -fn analyze_rust_project( - root: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - build_scripts: &mut Vec, - config: &AnalysisConfig, -) -> Result<()> { - let cargo_toml = root.join("Cargo.toml"); - - if is_readable_file(&cargo_toml) { - let content = read_file_safe(&cargo_toml, config.max_file_size)?; - if let Ok(toml_value) = toml::from_str::(&content) { - // Check for binary targets - if let Some(bins) = toml_value.get("bin").and_then(|b| b.as_array()) { - for bin in bins { - if let Some(name) = bin.get("name").and_then(|n| n.as_str()) { - let path = bin.get("path") - .and_then(|p| p.as_str()) - .map(PathBuf::from) - .unwrap_or_else(|| root.join("src").join("bin").join(format!("{}.rs", name))); - - entry_points.push(EntryPoint { - file: path, - function: Some("main".to_string()), - command: Some(format!("cargo run --bin {}", name)), - }); - } - } - } - - // Default binary - if let Some(_package_name) = toml_value.get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) { - let main_rs = root.join("src").join("main.rs"); - if is_readable_file(&main_rs) { - entry_points.push(EntryPoint { - file: main_rs.clone(), - function: Some("main".to_string()), - command: Some("cargo run".to_string()), - }); - - // Scan main.rs for context - scan_rust_file_for_context(&main_rs, ports, env_vars, config)?; - } - } - } - } - - // Common Rust build commands - build_scripts.extend(vec![ - BuildScript { - name: "build".to_string(), - command: "cargo build".to_string(), - description: Some("Build the project".to_string()), - is_default: false, - }, - BuildScript { - name: "build-release".to_string(), - command: "cargo build --release".to_string(), - description: Some("Build optimized release version".to_string()), - is_default: false, - }, - BuildScript { - name: "test".to_string(), - command: "cargo test".to_string(), - description: Some("Run tests".to_string()), - is_default: false, - }, - BuildScript { - name: "run".to_string(), - command: "cargo run".to_string(), - description: Some("Run the application".to_string()), - is_default: true, - }, - ]); - - Ok(()) -} - -/// Scans Rust files for context information -fn scan_rust_file_for_context( - path: &Path, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - config: &AnalysisConfig, -) -> Result<()> { - let content = read_file_safe(path, config.max_file_size)?; - - // Look for port bindings - let port_patterns = [ - r#"bind\s*\(\s*"[^"]*:(\d{1,5})"\s*\)"#, - r#"bind\s*\(\s*\([^,]+,\s*(\d{1,5})\)\s*\)"#, - r#"listen\s*\(\s*"[^"]*:(\d{1,5})"\s*\)"#, - r"PORT[^=]*=\s*(\d{1,5})", - ]; - - for pattern in &port_patterns { - let regex = create_regex(pattern)?; - for cap in regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("Rust web server".to_string()), - }); - } - } - } - } - - // Look for environment variable usage - let env_patterns = [ - r#"env::var\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, - r#"std::env::var\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, - r#"env!\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, - ]; - - for pattern in &env_patterns { - let regex = create_regex(pattern)?; - for cap in regex.captures_iter(&content) { - if let Some(var_name) = cap.get(1) { - let name = var_name.as_str().to_string(); - env_vars.entry(name.clone()).or_insert((None, false, None)); - } - } - } - - Ok(()) -} - -/// Analyzes Go projects -fn analyze_go_project( - root: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - build_scripts: &mut Vec, - config: &AnalysisConfig, -) -> Result<()> { - // Check for main.go - let main_go = root.join("main.go"); - if is_readable_file(&main_go) { - entry_points.push(EntryPoint { - file: main_go.clone(), - function: Some("main".to_string()), - command: Some("go run main.go".to_string()), - }); - - scan_go_file_for_context(&main_go, ports, env_vars, config)?; - } - - // Check cmd directory for multiple binaries - let cmd_dir = root.join("cmd"); - if cmd_dir.is_dir() { - if let Ok(entries) = std::fs::read_dir(&cmd_dir) { - for entry in entries.flatten() { - if entry.file_type()?.is_dir() { - let main_file = entry.path().join("main.go"); - if is_readable_file(&main_file) { - let cmd_name = entry.file_name().to_string_lossy().to_string(); - entry_points.push(EntryPoint { - file: main_file.clone(), - function: Some("main".to_string()), - command: Some(format!("go run ./cmd/{}", cmd_name)), - }); - - scan_go_file_for_context(&main_file, ports, env_vars, config)?; - } - } - } - } - } - - // Common Go build commands - build_scripts.extend(vec![ - BuildScript { - name: "build".to_string(), - command: "go build".to_string(), - description: Some("Build the project".to_string()), - is_default: false, - }, - BuildScript { - name: "test".to_string(), - command: "go test ./...".to_string(), - description: Some("Run tests".to_string()), - is_default: false, - }, - BuildScript { - name: "run".to_string(), - command: "go run .".to_string(), - description: Some("Run the application".to_string()), - is_default: true, - }, - BuildScript { - name: "mod-download".to_string(), - command: "go mod download".to_string(), - description: Some("Download dependencies".to_string()), - is_default: false, - }, - ]); - - Ok(()) -} - -/// Scans Go files for context information -fn scan_go_file_for_context( - path: &Path, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - config: &AnalysisConfig, -) -> Result<()> { - let content = read_file_safe(path, config.max_file_size)?; - - // Look for port bindings - let port_patterns = [ - r#"Listen\s*\(\s*":(\d{1,5})"\s*\)"#, - r#"ListenAndServe\s*\(\s*":(\d{1,5})"\s*,"#, - r#"Addr:\s*":(\d{1,5})""#, - r"PORT[^=]*=\s*(\d{1,5})", - ]; - - for pattern in &port_patterns { - let regex = create_regex(pattern)?; - for cap in regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("Go web server".to_string()), - }); - } - } - } - } - - // Look for environment variable usage - let env_patterns = [ - r#"os\.Getenv\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, - r#"os\.LookupEnv\s*\(\s*"([A-Z_][A-Z0-9_]*)"\s*\)"#, - ]; - - for pattern in &env_patterns { - let regex = create_regex(pattern)?; - for cap in regex.captures_iter(&content) { - if let Some(var_name) = cap.get(1) { - let name = var_name.as_str().to_string(); - env_vars.entry(name.clone()).or_insert((None, false, None)); - } - } - } - - Ok(()) -} - -/// Analyzes JVM projects (Java/Kotlin) -fn analyze_jvm_project( - root: &Path, - _entry_points: &mut Vec, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - build_scripts: &mut Vec, - config: &AnalysisConfig, -) -> Result<()> { - // Check for Maven - let pom_xml = root.join("pom.xml"); - if is_readable_file(&pom_xml) { - build_scripts.extend(vec![ - BuildScript { - name: "build".to_string(), - command: "mvn clean package".to_string(), - description: Some("Build with Maven".to_string()), - is_default: false, - }, - BuildScript { - name: "test".to_string(), - command: "mvn test".to_string(), - description: Some("Run tests".to_string()), - is_default: false, - }, - BuildScript { - name: "run".to_string(), - command: "mvn spring-boot:run".to_string(), - description: Some("Run Spring Boot application".to_string()), - is_default: true, - }, - ]); - } - - // Check for Gradle - let gradle_files = ["build.gradle", "build.gradle.kts"]; - for gradle_file in &gradle_files { - if is_readable_file(&root.join(gradle_file)) { - build_scripts.extend(vec![ - BuildScript { - name: "build".to_string(), - command: "./gradlew build".to_string(), - description: Some("Build with Gradle".to_string()), - is_default: false, - }, - BuildScript { - name: "test".to_string(), - command: "./gradlew test".to_string(), - description: Some("Run tests".to_string()), - is_default: false, - }, - BuildScript { - name: "run".to_string(), - command: "./gradlew bootRun".to_string(), - description: Some("Run Spring Boot application".to_string()), - is_default: true, - }, - ]); - break; - } - } - - // Look for application properties - let app_props_locations = [ - "src/main/resources/application.properties", - "src/main/resources/application.yml", - "src/main/resources/application.yaml", - ]; - - for props_path in &app_props_locations { - let full_path = root.join(props_path); - if is_readable_file(&full_path) { - analyze_application_properties(&full_path, ports, env_vars, config)?; - } - } - - Ok(()) -} - -/// Analyzes application properties files -fn analyze_application_properties( - path: &Path, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, - config: &AnalysisConfig, -) -> Result<()> { - let content = read_file_safe(path, config.max_file_size)?; - - // Look for server.port - let port_regex = create_regex(r"server\.port\s*[=:]\s*(\d{1,5})")?; - for cap in port_regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("Spring Boot server".to_string()), - }); - } - } - } - - // Look for ${ENV_VAR} placeholders - let env_regex = create_regex(r"\$\{([A-Z_][A-Z0-9_]*)\}")?; - for cap in env_regex.captures_iter(&content) { - if let Some(var_name) = cap.get(1) { - let name = var_name.as_str().to_string(); - env_vars.entry(name.clone()).or_insert((None, false, None)); - } - } - - Ok(()) -} - -/// Analyzes Docker files for ports and environment variables -fn analyze_docker_files( - root: &Path, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, -) -> Result<()> { - let dockerfile = root.join("Dockerfile"); - - if is_readable_file(&dockerfile) { - let content = std::fs::read_to_string(&dockerfile)?; - - // Look for EXPOSE directives - let expose_regex = create_regex(r"EXPOSE\s+(\d{1,5})(?:/(\w+))?")?; - for cap in expose_regex.captures_iter(&content) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - let protocol = cap.get(2) - .and_then(|p| match p.as_str().to_lowercase().as_str() { - "tcp" => Some(Protocol::Tcp), - "udp" => Some(Protocol::Udp), - _ => None, - }) - .unwrap_or(Protocol::Tcp); - - ports.insert(Port { - number: port, - protocol, - description: Some("Exposed in Dockerfile".to_string()), - }); - } - } - } - - // Look for ENV directives - let env_regex = create_regex(r"ENV\s+([A-Z_][A-Z0-9_]*)\s+(.+)")?; - for cap in env_regex.captures_iter(&content) { - if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) { - let var_name = name.as_str().to_string(); - let var_value = value.as_str().trim().to_string(); - env_vars.entry(var_name).or_insert((Some(var_value), false, None)); - } - } - } - - // Check docker-compose files - let compose_files = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]; - for compose_file in &compose_files { - let path = root.join(compose_file); - if is_readable_file(&path) { - analyze_docker_compose(&path, ports, env_vars)?; - break; - } - } - - Ok(()) -} - -/// Analyzes docker-compose files -fn analyze_docker_compose( - path: &Path, - ports: &mut HashSet, - env_vars: &mut HashMap, bool, Option)>, -) -> Result<()> { - let content = std::fs::read_to_string(path)?; - let value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| AnalysisError::InvalidStructure(format!("Invalid YAML: {}", e)))?; - - if let Some(services) = value.get("services").and_then(|s| s.as_mapping()) { - for (service_name, service) in services { - let service_name_str = service_name.as_str().unwrap_or("unknown"); - - // Determine service type based on image, name, and other indicators - let service_type = determine_service_type(service_name_str, service); - - // Extract ports - if let Some(service_ports) = service.get("ports").and_then(|p| p.as_sequence()) { - for port_entry in service_ports { - if let Some(port_str) = port_entry.as_str() { - // Parse port mappings like "8080:80" or just "80" - let parts: Vec<&str> = port_str.split(':').collect(); - - let (external_port, internal_port, protocol_suffix) = if parts.len() >= 2 { - // Format: "external:internal" or "external:internal/protocol" - let external = parts[0].trim(); - let internal_parts: Vec<&str> = parts[1].split('/').collect(); - let internal = internal_parts[0].trim(); - let protocol = internal_parts.get(1).map(|p| p.trim()); - (external, internal, protocol) - } else { - // Format: just "port" or "port/protocol" - let port_parts: Vec<&str> = parts[0].split('/').collect(); - let port = port_parts[0].trim(); - let protocol = port_parts.get(1).map(|p| p.trim()); - (port, port, protocol) - }; - - // Determine protocol - let protocol = match protocol_suffix { - Some("udp") => Protocol::Udp, - _ => Protocol::Tcp, - }; - - // Create descriptive port entry - if let Ok(port) = external_port.parse::() { - let description = create_port_description(&service_type, service_name_str, external_port, internal_port); - - ports.insert(Port { - number: port, - protocol, - description: Some(description), - }); - } - } - } - } - - // Extract environment variables with context - if let Some(env) = service.get("environment") { - let env_context = format!(" ({})", service_type.as_str()); - - if let Some(env_map) = env.as_mapping() { - for (key, value) in env_map { - if let Some(key_str) = key.as_str() { - let val_str = value.as_str().map(|s| s.to_string()); - let description = get_env_var_description(key_str, &service_type); - env_vars.entry(key_str.to_string()) - .or_insert((val_str, false, description.or_else(|| Some(env_context.clone())))); - } - } - } else if let Some(env_list) = env.as_sequence() { - for item in env_list { - if let Some(env_str) = item.as_str() { - if let Some(eq_pos) = env_str.find('=') { - let (key, value) = env_str.split_at(eq_pos); - let value = &value[1..]; // Skip the '=' - let description = get_env_var_description(key, &service_type); - env_vars.entry(key.to_string()) - .or_insert((Some(value.to_string()), false, description.or_else(|| Some(env_context.clone())))); - } - } - } - } - } - } - } - - Ok(()) -} - -/// Service types found in Docker Compose -#[derive(Debug, Clone)] -enum ServiceType { - PostgreSQL, - MySQL, - MongoDB, - Redis, - RabbitMQ, - Kafka, - Elasticsearch, - Application, - Nginx, - Unknown, -} - -impl ServiceType { - fn as_str(&self) -> &'static str { - match self { - ServiceType::PostgreSQL => "PostgreSQL database", - ServiceType::MySQL => "MySQL database", - ServiceType::MongoDB => "MongoDB database", - ServiceType::Redis => "Redis cache", - ServiceType::RabbitMQ => "RabbitMQ message broker", - ServiceType::Kafka => "Kafka message broker", - ServiceType::Elasticsearch => "Elasticsearch search engine", - ServiceType::Application => "Application service", - ServiceType::Nginx => "Nginx web server", - ServiceType::Unknown => "Service", - } - } -} - -/// Determines the type of service based on various indicators -fn determine_service_type(name: &str, service: &serde_yaml::Value) -> ServiceType { - let name_lower = name.to_lowercase(); - - // Check service name - if name_lower.contains("postgres") || name_lower.contains("pg") || name_lower.contains("psql") { - return ServiceType::PostgreSQL; - } else if name_lower.contains("mysql") || name_lower.contains("mariadb") { - return ServiceType::MySQL; - } else if name_lower.contains("mongo") { - return ServiceType::MongoDB; - } else if name_lower.contains("redis") { - return ServiceType::Redis; - } else if name_lower.contains("rabbit") || name_lower.contains("amqp") { - return ServiceType::RabbitMQ; - } else if name_lower.contains("kafka") { - return ServiceType::Kafka; - } else if name_lower.contains("elastic") || name_lower.contains("es") { - return ServiceType::Elasticsearch; - } else if name_lower.contains("nginx") || name_lower.contains("proxy") { - return ServiceType::Nginx; - } - - // Check image name - if let Some(image) = service.get("image").and_then(|i| i.as_str()) { - let image_lower = image.to_lowercase(); - if image_lower.contains("postgres") { - return ServiceType::PostgreSQL; - } else if image_lower.contains("mysql") || image_lower.contains("mariadb") { - return ServiceType::MySQL; - } else if image_lower.contains("mongo") { - return ServiceType::MongoDB; - } else if image_lower.contains("redis") { - return ServiceType::Redis; - } else if image_lower.contains("rabbitmq") { - return ServiceType::RabbitMQ; - } else if image_lower.contains("kafka") { - return ServiceType::Kafka; - } else if image_lower.contains("elastic") { - return ServiceType::Elasticsearch; - } else if image_lower.contains("nginx") { - return ServiceType::Nginx; - } - } - - // Check environment variables for clues - if let Some(env) = service.get("environment") { - if let Some(env_map) = env.as_mapping() { - for (key, _) in env_map { - if let Some(key_str) = key.as_str() { - if key_str.contains("POSTGRES") || key_str.contains("PGPASSWORD") { - return ServiceType::PostgreSQL; - } else if key_str.contains("MYSQL") { - return ServiceType::MySQL; - } else if key_str.contains("MONGO") { - return ServiceType::MongoDB; - } - } - } - } - } - - // Check if it has a build context (likely application) - if service.get("build").is_some() { - return ServiceType::Application; - } - - ServiceType::Unknown -} - -/// Creates a descriptive port description based on service type -fn create_port_description(service_type: &ServiceType, service_name: &str, external: &str, internal: &str) -> String { - let base_desc = match service_type { - ServiceType::PostgreSQL => format!("PostgreSQL database ({})", service_name), - ServiceType::MySQL => format!("MySQL database ({})", service_name), - ServiceType::MongoDB => format!("MongoDB database ({})", service_name), - ServiceType::Redis => format!("Redis cache ({})", service_name), - ServiceType::RabbitMQ => format!("RabbitMQ message broker ({})", service_name), - ServiceType::Kafka => format!("Kafka message broker ({})", service_name), - ServiceType::Elasticsearch => format!("Elasticsearch ({})", service_name), - ServiceType::Nginx => format!("Nginx proxy ({})", service_name), - ServiceType::Application => format!("Application service ({})", service_name), - ServiceType::Unknown => format!("Docker service ({})", service_name), - }; - - if external != internal { - format!("{} - external:{}, internal:{}", base_desc, external, internal) - } else { - format!("{} - port {}", base_desc, external) - } -} - -/// Gets a descriptive context for environment variables based on service type -fn get_env_var_description(var_name: &str, service_type: &ServiceType) -> Option { - match var_name { - "POSTGRES_PASSWORD" | "POSTGRES_USER" | "POSTGRES_DB" => - Some("PostgreSQL configuration".to_string()), - "MYSQL_ROOT_PASSWORD" | "MYSQL_PASSWORD" | "MYSQL_USER" | "MYSQL_DATABASE" => - Some("MySQL configuration".to_string()), - "MONGO_INITDB_ROOT_USERNAME" | "MONGO_INITDB_ROOT_PASSWORD" => - Some("MongoDB configuration".to_string()), - "REDIS_PASSWORD" => Some("Redis configuration".to_string()), - "RABBITMQ_DEFAULT_USER" | "RABBITMQ_DEFAULT_PASS" => - Some("RabbitMQ configuration".to_string()), - "DATABASE_URL" | "DB_CONNECTION_STRING" => - Some("Database connection string".to_string()), - "GOOGLE_APPLICATION_CREDENTIALS" => - Some("Google Cloud service account credentials".to_string()), - _ => None, - } -} - -/// Analyzes .env files -fn analyze_env_files( - root: &Path, - env_vars: &mut HashMap, bool, Option)>, -) -> Result<()> { - let env_files = [".env", ".env.example", ".env.local", ".env.development", ".env.production"]; - - for env_file in &env_files { - let path = root.join(env_file); - if is_readable_file(&path) { - let content = std::fs::read_to_string(&path)?; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - if let Some(eq_pos) = line.find('=') { - let (key, value) = line.split_at(eq_pos); - let key = key.trim(); - let value = value[1..].trim(); // Skip the '=' - - // Check if it's marked as required (common convention) - let required = value.is_empty() || value == "required" || value == "REQUIRED"; - let actual_value = if required { None } else { Some(value.to_string()) }; - - env_vars.entry(key.to_string()).or_insert((actual_value, required, None)); - } - } - } - } - - Ok(()) -} - -/// Analyzes Makefile for build scripts -fn analyze_makefile( - root: &Path, - build_scripts: &mut Vec, -) -> Result<()> { - let makefiles = ["Makefile", "makefile"]; - - for makefile in &makefiles { - let path = root.join(makefile); - if is_readable_file(&path) { - let content = std::fs::read_to_string(&path)?; - - // Simple Makefile target extraction - let target_regex = create_regex(r"^([a-zA-Z0-9_-]+):\s*(?:[^\n]*)?$")?; - let mut in_recipe = false; - let mut current_target = String::new(); - let mut current_command = String::new(); - - for line in content.lines() { - if let Some(cap) = target_regex.captures(line) { - // Save previous target if any - if !current_target.is_empty() && !current_command.is_empty() { - build_scripts.push(BuildScript { - name: current_target.clone(), - command: format!("make {}", current_target), - description: None, - is_default: current_target == "run" || current_target == "start", - }); - } - - if let Some(target) = cap.get(1) { - current_target = target.as_str().to_string(); - current_command.clear(); - in_recipe = true; - } - } else if in_recipe && line.starts_with('\t') { - if current_command.is_empty() { - current_command = line.trim().to_string(); - } - } else if !line.trim().is_empty() { - in_recipe = false; - } - } - - // Save last target - if !current_target.is_empty() && !current_command.is_empty() { - build_scripts.push(BuildScript { - name: current_target.clone(), - command: format!("make {}", current_target), - description: None, - is_default: current_target == "run" || current_target == "start", - }); - } - - break; - } - } - - Ok(()) -} - -/// Analyzes technology-specific configurations -fn analyze_technology_specifics( - technology: &DetectedTechnology, - root: &Path, - entry_points: &mut Vec, - ports: &mut HashSet, -) -> Result<()> { - match technology.name.as_str() { - "Next.js" => { - // Next.js typically runs on port 3000 - ports.insert(Port { - number: 3000, - protocol: Protocol::Http, - description: Some("Next.js development server".to_string()), - }); - - // Look for pages directory - let pages_dir = root.join("pages"); - if pages_dir.is_dir() { - entry_points.push(EntryPoint { - file: pages_dir, - function: None, - command: Some("npm run dev".to_string()), - }); - } - } - "Express" | "Fastify" | "Koa" | "Hono" | "Elysia" => { - // Common Node.js web framework ports - ports.insert(Port { - number: 3000, - protocol: Protocol::Http, - description: Some(format!("{} server", technology.name)), - }); - } - "Encore" => { - // Encore development server typically runs on port 4000 - ports.insert(Port { - number: 4000, - protocol: Protocol::Http, - description: Some("Encore development server".to_string()), - }); - } - "Astro" => { - // Astro development server typically runs on port 3000 or 4321 - ports.insert(Port { - number: 4321, - protocol: Protocol::Http, - description: Some("Astro development server".to_string()), - }); - } - "SvelteKit" => { - // SvelteKit development server typically runs on port 5173 - ports.insert(Port { - number: 5173, - protocol: Protocol::Http, - description: Some("SvelteKit development server".to_string()), - }); - } - "Nuxt.js" => { - // Nuxt.js development server typically runs on port 3000 - ports.insert(Port { - number: 3000, - protocol: Protocol::Http, - description: Some("Nuxt.js development server".to_string()), - }); - } - "Tanstack Start" => { - // Modern React framework typically runs on port 3000 - ports.insert(Port { - number: 3000, - protocol: Protocol::Http, - description: Some(format!("{} development server", technology.name)), - }); - } - "React Router v7" => { - // React Router v7 development server typically runs on port 5173 - ports.insert(Port { - number: 5173, - protocol: Protocol::Http, - description: Some("React Router v7 development server".to_string()), - }); - } - "Django" => { - ports.insert(Port { - number: 8000, - protocol: Protocol::Http, - description: Some("Django development server".to_string()), - }); - } - "Flask" | "FastAPI" => { - ports.insert(Port { - number: 5000, - protocol: Protocol::Http, - description: Some(format!("{} server", technology.name)), - }); - } - "Spring Boot" => { - ports.insert(Port { - number: 8080, - protocol: Protocol::Http, - description: Some("Spring Boot server".to_string()), - }); - } - "Actix Web" | "Rocket" => { - ports.insert(Port { - number: 8080, - protocol: Protocol::Http, - description: Some(format!("{} server", technology.name)), - }); - } - _ => {} - } - - Ok(()) -} - -/// Extracts ports from command strings -fn extract_ports_from_command(command: &str, ports: &mut HashSet) { - // Look for common port patterns in commands - let patterns = [ - r"-p\s+(\d{1,5})", - r"--port\s+(\d{1,5})", - r"--port=(\d{1,5})", - r"PORT=(\d{1,5})", - ]; - - for pattern in &patterns { - if let Ok(regex) = Regex::new(pattern) { - for cap in regex.captures_iter(command) { - if let Some(port_str) = cap.get(1) { - if let Ok(port) = port_str.as_str().parse::() { - ports.insert(Port { - number: port, - protocol: Protocol::Http, - description: Some("Port from command".to_string()), - }); - } - } - } - } - } -} - -/// Helper function to get script description -fn get_script_description(name: &str) -> Option { - match name { - "start" => Some("Start the application".to_string()), - "dev" => Some("Start development server".to_string()), - "build" => Some("Build the application".to_string()), - "test" => Some("Run tests".to_string()), - "lint" => Some("Run linter".to_string()), - "format" => Some("Format code".to_string()), - _ => None, - } -} - -/// Determines the project type based on analysis -fn determine_project_type( - languages: &[DetectedLanguage], - technologies: &[DetectedTechnology], - entry_points: &[EntryPoint], - ports: &[Port], -) -> ProjectType { - // Check for microservice architecture indicators - 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") - } else { - false - } - }); - - 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; - - 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 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() - .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() - .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() - .any(|t| static_generators.contains(&t.name.as_str())); - - // Determine type based on indicators - if (has_database_ports || has_multiple_services) && (has_orchestration_framework || has_api_framework) { - ProjectType::Microservice - } else if has_static_generator { - ProjectType::StaticSite - } else if has_api_framework && !has_web_framework { - ProjectType::ApiService - } else if has_web_framework { - ProjectType::WebApplication - } 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, - } - }); - - if has_lib_indicators { - ProjectType::Library - } else { - ProjectType::Unknown - } - } else { - ProjectType::Unknown - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::analyzer::{TechnologyCategory, LibraryType}; - use std::fs; - use tempfile::TempDir; - - fn create_test_language(name: &str) -> DetectedLanguage { - DetectedLanguage { - name: name.to_string(), - version: None, - confidence: 0.9, - files: vec![], - main_dependencies: vec![], - dev_dependencies: vec![], - package_manager: None, - } - } - - fn create_test_technology(name: &str, category: TechnologyCategory) -> DetectedTechnology { - DetectedTechnology { - name: name.to_string(), - version: None, - category, - confidence: 0.8, - requires: vec![], - conflicts_with: vec![], - is_primary: false, - } - } - - #[test] - fn test_node_project_context() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create package.json with scripts - let package_json = r#"{ - "name": "test-app", - "main": "index.js", - "scripts": { - "start": "node index.js", - "dev": "nodemon index.js", - "test": "jest", - "build": "webpack" - } - }"#; - fs::write(root.join("package.json"), package_json).unwrap(); - - // Create index.js with port and env vars - let index_js = r#" -const express = require('express'); -const app = express(); - -const PORT = process.env.PORT || 3000; -const API_KEY = process.env.API_KEY; -const DATABASE_URL = process.env.DATABASE_URL; - -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); -}); - "#; - fs::write(root.join("index.js"), index_js).unwrap(); - - let languages = vec![create_test_language("JavaScript")]; - let technologies = vec![create_test_technology("Express", TechnologyCategory::BackendFramework)]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &technologies, &config).unwrap(); - - // Verify entry points - assert!(!context.entry_points.is_empty()); - assert!(context.entry_points.iter().any(|ep| ep.file.ends_with("index.js"))); - - // Verify ports - assert!(!context.ports.is_empty()); - assert!(context.ports.iter().any(|p| p.number == 3000)); - - // Verify environment variables - assert!(context.environment_variables.iter().any(|ev| ev.name == "PORT")); - assert!(context.environment_variables.iter().any(|ev| ev.name == "API_KEY")); - assert!(context.environment_variables.iter().any(|ev| ev.name == "DATABASE_URL")); - - // Verify build scripts - assert_eq!(context.build_scripts.len(), 4); - assert!(context.build_scripts.iter().any(|bs| bs.name == "start" && bs.is_default)); - assert!(context.build_scripts.iter().any(|bs| bs.name == "dev" && bs.is_default)); - assert!(context.build_scripts.iter().any(|bs| bs.name == "test")); - assert!(context.build_scripts.iter().any(|bs| bs.name == "build")); - - // Verify project type - assert_eq!(context.project_type, ProjectType::WebApplication); - } - - #[test] - fn test_python_project_context() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create app.py with Flask - let app_py = r#" -import os -from flask import Flask - -app = Flask(__name__) - -PORT = 5000 -SECRET_KEY = os.environ.get('SECRET_KEY') -DEBUG = os.getenv('DEBUG', 'False') - -if __name__ == '__main__': - app.run(port=PORT) - "#; - fs::write(root.join("app.py"), app_py).unwrap(); - - let languages = vec![create_test_language("Python")]; - let technologies = vec![create_test_technology("Flask", TechnologyCategory::BackendFramework)]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &technologies, &config).unwrap(); - - // Verify entry points - assert!(context.entry_points.iter().any(|ep| ep.file.ends_with("app.py"))); - - // Verify ports - assert!(context.ports.iter().any(|p| p.number == 5000)); - - // Verify environment variables - assert!(context.environment_variables.iter().any(|ev| ev.name == "SECRET_KEY")); - assert!(context.environment_variables.iter().any(|ev| ev.name == "DEBUG")); - - // Verify project type - assert_eq!(context.project_type, ProjectType::WebApplication); - } - - #[test] - fn test_rust_project_context() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create Cargo.toml - let cargo_toml = r#" -[package] -name = "test-server" -version = "0.1.0" - -[[bin]] -name = "server" -path = "src/main.rs" - "#; - fs::write(root.join("Cargo.toml"), cargo_toml).unwrap(); - - // Create src directory - fs::create_dir_all(root.join("src")).unwrap(); - - // Create main.rs - let main_rs = r#" -use std::env; - -fn main() { - let port = env::var("PORT").unwrap_or_else(|_| "8080".to_string()); - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - - println!("Starting server on port {}", port); -} - "#; - fs::write(root.join("src/main.rs"), main_rs).unwrap(); - - let languages = vec![create_test_language("Rust")]; - let frameworks = vec![]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &frameworks, &config).unwrap(); - - // Verify entry points - assert!(context.entry_points.iter().any(|ep| ep.file.ends_with("main.rs"))); - assert!(context.entry_points.iter().any(|ep| ep.command == Some("cargo run".to_string()))); - - // Verify build scripts - assert!(context.build_scripts.iter().any(|bs| bs.name == "build")); - assert!(context.build_scripts.iter().any(|bs| bs.name == "test")); - assert!(context.build_scripts.iter().any(|bs| bs.name == "run" && bs.is_default)); - - // Verify environment variables - assert!(context.environment_variables.iter().any(|ev| ev.name == "PORT")); - assert!(context.environment_variables.iter().any(|ev| ev.name == "DATABASE_URL")); - } - - #[test] - fn test_dockerfile_analysis() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create Dockerfile - let dockerfile = r#" -FROM node:14 -WORKDIR /app - -ENV NODE_ENV=production -ENV PORT=3000 - -EXPOSE 3000 -EXPOSE 9229/tcp - -CMD ["node", "server.js"] - "#; - fs::write(root.join("Dockerfile"), dockerfile).unwrap(); - - let languages = vec![]; - let frameworks = vec![]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &frameworks, &config).unwrap(); - - // Verify ports from EXPOSE - assert!(context.ports.iter().any(|p| p.number == 3000)); - assert!(context.ports.iter().any(|p| p.number == 9229 && p.protocol == Protocol::Tcp)); - - // Verify environment variables from ENV - assert!(context.environment_variables.iter().any(|ev| - ev.name == "NODE_ENV" && ev.default_value == Some("production".to_string()) - )); - assert!(context.environment_variables.iter().any(|ev| - ev.name == "PORT" && ev.default_value == Some("3000".to_string()) - )); - } - - #[test] - fn test_docker_compose_analysis() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create docker-compose.yml - let compose = r#" -version: '3.8' -services: - web: - build: . - ports: - - "8080:80" - - "443" - environment: - - DATABASE_URL=postgres://user:pass@db:5432/mydb - - REDIS_URL=redis://cache:6379 - db: - image: postgres - ports: - - "5432" - environment: - POSTGRES_PASSWORD: secret - "#; - fs::write(root.join("docker-compose.yml"), compose).unwrap(); - - let languages = vec![]; - let frameworks = vec![]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &frameworks, &config).unwrap(); - - // Verify ports - assert!(context.ports.iter().any(|p| p.number == 80)); - assert!(context.ports.iter().any(|p| p.number == 443)); - assert!(context.ports.iter().any(|p| p.number == 5432)); - - // Verify environment variables - assert!(context.environment_variables.iter().any(|ev| ev.name == "DATABASE_URL")); - assert!(context.environment_variables.iter().any(|ev| ev.name == "REDIS_URL")); - assert!(context.environment_variables.iter().any(|ev| ev.name == "POSTGRES_PASSWORD")); - } - - #[test] - fn test_env_file_analysis() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create .env file - let env_file = r#" -# Database configuration -DATABASE_URL=postgresql://localhost:5432/myapp -REDIS_URL=redis://localhost:6379 - -# API Keys -API_KEY= -SECRET_KEY=required - -# Feature flags -ENABLE_FEATURE_X=true -DEBUG=false - "#; - fs::write(root.join(".env"), env_file).unwrap(); - - let languages = vec![]; - let frameworks = vec![]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &frameworks, &config).unwrap(); - - // Verify environment variables - assert!(context.environment_variables.iter().any(|ev| - ev.name == "DATABASE_URL" && ev.default_value.is_some() - )); - assert!(context.environment_variables.iter().any(|ev| - ev.name == "API_KEY" && ev.required - )); - assert!(context.environment_variables.iter().any(|ev| - ev.name == "SECRET_KEY" && ev.required - )); - assert!(context.environment_variables.iter().any(|ev| - ev.name == "ENABLE_FEATURE_X" && ev.default_value == Some("true".to_string()) - )); - } - - #[test] - fn test_makefile_analysis() { - let temp_dir = TempDir::new().unwrap(); - let root = temp_dir.path(); - - // Create Makefile - let makefile = r#" -build: - go build -o app main.go - -test: - go test ./... - -run: build - ./app - -docker-build: - docker build -t myapp . - -clean: - rm -f app - "#; - fs::write(root.join("Makefile"), makefile).unwrap(); - - let languages = vec![]; - let frameworks = vec![]; - let config = AnalysisConfig::default(); - - let context = analyze_context(root, &languages, &frameworks, &config).unwrap(); - - // Verify build scripts - assert!(context.build_scripts.iter().any(|bs| bs.name == "build")); - assert!(context.build_scripts.iter().any(|bs| bs.name == "test")); - assert!(context.build_scripts.iter().any(|bs| bs.name == "run" && bs.is_default)); - assert!(context.build_scripts.iter().any(|bs| bs.name == "docker-build")); - assert!(context.build_scripts.iter().any(|bs| bs.name == "clean")); - } - - #[test] - fn test_project_type_detection() { - // Test CLI tool detection - let languages = vec![create_test_language("Rust")]; - let technologies = vec![create_test_technology("clap", TechnologyCategory::Library(LibraryType::Other("CLI".to_string())))]; - let entry_points = vec![EntryPoint { - file: PathBuf::from("src/main.rs"), - function: Some("main".to_string()), - command: Some("cargo run".to_string()), - }]; - let ports = vec![]; - - let project_type = determine_project_type(&languages, &technologies, &entry_points, &ports); - assert_eq!(project_type, ProjectType::CliTool); - - // Test API service detection - let technologies = vec![create_test_technology("FastAPI", TechnologyCategory::BackendFramework)]; - let ports = vec![Port { - number: 8000, - protocol: Protocol::Http, - description: None, - }]; - - let project_type = determine_project_type(&languages, &technologies, &vec![], &ports); - assert_eq!(project_type, ProjectType::ApiService); - - // Test library detection - let languages = vec![create_test_language("Python")]; - let mut lang = languages[0].clone(); - lang.files = vec![PathBuf::from("__init__.py")]; - let languages = vec![lang]; - - let project_type = determine_project_type(&languages, &vec![], &vec![], &vec![]); - assert_eq!(project_type, ProjectType::Library); - } -} \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 7e1f2d8a..32059c21 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,9 @@ use std::path::PathBuf; #[command(name = "sync-ctl")] #[command(version = env!("CARGO_PKG_VERSION"))] #[command(about = "Generate Infrastructure as Code from your codebase")] -#[command(long_about = "A powerful CLI tool that analyzes your codebase and automatically generates optimized Infrastructure as Code configurations including Dockerfiles, Docker Compose files, and Terraform configurations.")] +#[command( + long_about = "A powerful CLI tool that analyzes your codebase and automatically generates optimized Infrastructure as Code configurations including Dockerfiles, Docker Compose files, and Terraform configurations." +)] pub struct Cli { #[command(subcommand)] pub command: Commands, @@ -287,7 +289,7 @@ pub enum DisplayFormat { /// Compact matrix/dashboard view (modern, easy to scan) Matrix, /// Detailed vertical view (legacy format with all details) - Detailed, + Detailed, /// Brief summary only Summary, } @@ -332,4 +334,4 @@ impl Cli { .filter_level(level) .init(); } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index c433fe7f..40d6b1f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -343,7 +343,7 @@ pub fn handle_analyze( only: Option>, ) -> syncable_cli::Result<()> { // Call the handler from the handlers module which returns a string - let output = syncable_cli::handlers::analyze::handle_analyze( + syncable_cli::handlers::analyze::handle_analyze( path, json, detailed,