|
| 1 | +//! Health endpoint detection for deployment recommendations. |
| 2 | +//! |
| 3 | +//! Detects health check endpoints by analyzing: |
| 4 | +//! - Source code patterns (route definitions) |
| 5 | +//! - Framework conventions (Spring Actuator, etc.) |
| 6 | +//! - Configuration files (K8s manifests) |
| 7 | +
|
| 8 | +use crate::analyzer::{DetectedTechnology, HealthEndpoint, HealthEndpointSource, TechnologyCategory}; |
| 9 | +use crate::common::file_utils::{is_readable_file, read_file_safe}; |
| 10 | +use crate::error::Result; |
| 11 | +use regex::Regex; |
| 12 | +use std::path::Path; |
| 13 | + |
| 14 | +/// Common health check paths to scan for |
| 15 | +const COMMON_HEALTH_PATHS: &[&str] = &[ |
| 16 | + "/health", |
| 17 | + "/healthz", |
| 18 | + "/ready", |
| 19 | + "/readyz", |
| 20 | + "/livez", |
| 21 | + "/live", |
| 22 | + "/api/health", |
| 23 | + "/api/v1/health", |
| 24 | + "/__health", |
| 25 | + "/ping", |
| 26 | + "/status", |
| 27 | +]; |
| 28 | + |
| 29 | +/// Detects health endpoints from project analysis |
| 30 | +pub fn detect_health_endpoints( |
| 31 | + project_root: &Path, |
| 32 | + technologies: &[DetectedTechnology], |
| 33 | + max_file_size: usize, |
| 34 | +) -> Vec<HealthEndpoint> { |
| 35 | + let mut endpoints = Vec::new(); |
| 36 | + |
| 37 | + // Check framework-specific defaults first |
| 38 | + for tech in technologies { |
| 39 | + if let Some(endpoint) = get_framework_health_endpoint(tech) { |
| 40 | + endpoints.push(endpoint); |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + // Scan source files for health route definitions |
| 45 | + let detected_from_code = scan_for_health_routes(project_root, technologies, max_file_size); |
| 46 | + for endpoint in detected_from_code { |
| 47 | + // Avoid duplicates - prefer code-detected over framework defaults |
| 48 | + if !endpoints.iter().any(|e| e.path == endpoint.path) { |
| 49 | + endpoints.push(endpoint); |
| 50 | + } else { |
| 51 | + // Upgrade existing endpoint if code detection has higher confidence |
| 52 | + if let Some(existing) = endpoints.iter_mut().find(|e| e.path == endpoint.path) { |
| 53 | + if endpoint.confidence > existing.confidence { |
| 54 | + *existing = endpoint; |
| 55 | + } |
| 56 | + } |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + // Sort by confidence (highest first) |
| 61 | + endpoints.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal)); |
| 62 | + |
| 63 | + endpoints |
| 64 | +} |
| 65 | + |
| 66 | +/// Get framework-specific health endpoint defaults |
| 67 | +fn get_framework_health_endpoint(tech: &DetectedTechnology) -> Option<HealthEndpoint> { |
| 68 | + match tech.name.as_str() { |
| 69 | + // Java frameworks |
| 70 | + "Spring Boot" => Some(HealthEndpoint::from_framework("/actuator/health", "Spring Boot Actuator")), |
| 71 | + "Quarkus" => Some(HealthEndpoint::from_framework("/q/health", "Quarkus SmallRye Health")), |
| 72 | + "Micronaut" => Some(HealthEndpoint::from_framework("/health", "Micronaut")), |
| 73 | + |
| 74 | + // Node.js frameworks - no standard, but common patterns |
| 75 | + "Express" | "Fastify" | "Koa" | "Hono" | "Elysia" | "NestJS" => { |
| 76 | + // Return a lower confidence endpoint since these don't have a standard |
| 77 | + Some(HealthEndpoint { |
| 78 | + path: "/health".to_string(), |
| 79 | + confidence: 0.5, |
| 80 | + source: HealthEndpointSource::FrameworkDefault, |
| 81 | + description: Some(format!("{} common health pattern", tech.name)), |
| 82 | + }) |
| 83 | + } |
| 84 | + |
| 85 | + // Python frameworks |
| 86 | + "FastAPI" => Some(HealthEndpoint::from_framework("/health", "FastAPI")), |
| 87 | + "Django" => Some(HealthEndpoint { |
| 88 | + path: "/health/".to_string(), // Django uses trailing slashes |
| 89 | + confidence: 0.5, |
| 90 | + source: HealthEndpointSource::FrameworkDefault, |
| 91 | + description: Some("Django common health pattern".to_string()), |
| 92 | + }), |
| 93 | + "Flask" => Some(HealthEndpoint { |
| 94 | + path: "/health".to_string(), |
| 95 | + confidence: 0.5, |
| 96 | + source: HealthEndpointSource::FrameworkDefault, |
| 97 | + description: Some("Flask common health pattern".to_string()), |
| 98 | + }), |
| 99 | + |
| 100 | + // Go frameworks |
| 101 | + "Gin" | "Echo" | "Fiber" | "Chi" => Some(HealthEndpoint { |
| 102 | + path: "/health".to_string(), |
| 103 | + confidence: 0.5, |
| 104 | + source: HealthEndpointSource::FrameworkDefault, |
| 105 | + description: Some(format!("{} common health pattern", tech.name)), |
| 106 | + }), |
| 107 | + |
| 108 | + // Rust frameworks |
| 109 | + "Actix Web" | "Axum" | "Rocket" => Some(HealthEndpoint { |
| 110 | + path: "/health".to_string(), |
| 111 | + confidence: 0.5, |
| 112 | + source: HealthEndpointSource::FrameworkDefault, |
| 113 | + description: Some(format!("{} common health pattern", tech.name)), |
| 114 | + }), |
| 115 | + |
| 116 | + _ => None, |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +/// Scan source files for health route definitions |
| 121 | +fn scan_for_health_routes( |
| 122 | + project_root: &Path, |
| 123 | + technologies: &[DetectedTechnology], |
| 124 | + max_file_size: usize, |
| 125 | +) -> Vec<HealthEndpoint> { |
| 126 | + let mut endpoints = Vec::new(); |
| 127 | + |
| 128 | + // Determine which file types to scan based on detected technologies |
| 129 | + let has_js = technologies.iter().any(|t| { |
| 130 | + matches!(t.category, TechnologyCategory::BackendFramework | TechnologyCategory::MetaFramework) |
| 131 | + && (t.name.contains("Express") || t.name.contains("Fastify") || t.name.contains("Koa") |
| 132 | + || t.name.contains("Hono") || t.name.contains("Elysia") || t.name.contains("NestJS") |
| 133 | + || t.name.contains("Next") || t.name.contains("Nuxt")) |
| 134 | + }); |
| 135 | + |
| 136 | + let has_python = technologies.iter().any(|t| { |
| 137 | + matches!(t.category, TechnologyCategory::BackendFramework) |
| 138 | + && (t.name.contains("FastAPI") || t.name.contains("Flask") || t.name.contains("Django")) |
| 139 | + }); |
| 140 | + |
| 141 | + let has_go = technologies.iter().any(|t| { |
| 142 | + matches!(t.category, TechnologyCategory::BackendFramework) |
| 143 | + && (t.name.contains("Gin") || t.name.contains("Echo") || t.name.contains("Fiber") || t.name.contains("Chi")) |
| 144 | + }); |
| 145 | + |
| 146 | + let has_rust = technologies.iter().any(|t| { |
| 147 | + matches!(t.category, TechnologyCategory::BackendFramework) |
| 148 | + && (t.name.contains("Actix") || t.name.contains("Axum") || t.name.contains("Rocket")) |
| 149 | + }); |
| 150 | + |
| 151 | + let has_java = technologies.iter().any(|t| { |
| 152 | + matches!(t.category, TechnologyCategory::BackendFramework) |
| 153 | + && (t.name.contains("Spring") || t.name.contains("Quarkus") || t.name.contains("Micronaut")) |
| 154 | + }); |
| 155 | + |
| 156 | + // Common locations to check |
| 157 | + let locations = [ |
| 158 | + "src/", |
| 159 | + "app/", |
| 160 | + "routes/", |
| 161 | + "api/", |
| 162 | + "server/", |
| 163 | + "lib/", |
| 164 | + "handlers/", |
| 165 | + "controllers/", |
| 166 | + ]; |
| 167 | + |
| 168 | + for location in &locations { |
| 169 | + let dir = project_root.join(location); |
| 170 | + if dir.is_dir() { |
| 171 | + if has_js { |
| 172 | + scan_directory_for_patterns(&dir, &["js", "ts", "mjs"], &js_health_patterns(), max_file_size, &mut endpoints); |
| 173 | + } |
| 174 | + if has_python { |
| 175 | + scan_directory_for_patterns(&dir, &["py"], &python_health_patterns(), max_file_size, &mut endpoints); |
| 176 | + } |
| 177 | + if has_go { |
| 178 | + scan_directory_for_patterns(&dir, &["go"], &go_health_patterns(), max_file_size, &mut endpoints); |
| 179 | + } |
| 180 | + if has_rust { |
| 181 | + scan_directory_for_patterns(&dir, &["rs"], &rust_health_patterns(), max_file_size, &mut endpoints); |
| 182 | + } |
| 183 | + if has_java { |
| 184 | + scan_directory_for_patterns(&dir, &["java", "kt"], &java_health_patterns(), max_file_size, &mut endpoints); |
| 185 | + } |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + // Also check root-level files |
| 190 | + if has_js { |
| 191 | + for entry in ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js", "main.ts"] { |
| 192 | + let path = project_root.join(entry); |
| 193 | + if is_readable_file(&path) { |
| 194 | + scan_file_for_patterns(&path, &js_health_patterns(), max_file_size, &mut endpoints); |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + if has_python { |
| 199 | + for entry in ["main.py", "app.py", "wsgi.py", "asgi.py"] { |
| 200 | + let path = project_root.join(entry); |
| 201 | + if is_readable_file(&path) { |
| 202 | + scan_file_for_patterns(&path, &python_health_patterns(), max_file_size, &mut endpoints); |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + if has_go { |
| 207 | + let main_go = project_root.join("main.go"); |
| 208 | + if is_readable_file(&main_go) { |
| 209 | + scan_file_for_patterns(&main_go, &go_health_patterns(), max_file_size, &mut endpoints); |
| 210 | + } |
| 211 | + } |
| 212 | + if has_rust { |
| 213 | + let main_rs = project_root.join("src/main.rs"); |
| 214 | + if is_readable_file(&main_rs) { |
| 215 | + scan_file_for_patterns(&main_rs, &rust_health_patterns(), max_file_size, &mut endpoints); |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + endpoints |
| 220 | +} |
| 221 | + |
| 222 | +/// Scan a directory for health route patterns |
| 223 | +fn scan_directory_for_patterns( |
| 224 | + dir: &Path, |
| 225 | + extensions: &[&str], |
| 226 | + patterns: &[(&str, f32)], |
| 227 | + max_file_size: usize, |
| 228 | + endpoints: &mut Vec<HealthEndpoint>, |
| 229 | +) { |
| 230 | + if let Ok(entries) = std::fs::read_dir(dir) { |
| 231 | + for entry in entries.flatten() { |
| 232 | + let path = entry.path(); |
| 233 | + if path.is_file() { |
| 234 | + if let Some(ext) = path.extension() { |
| 235 | + if extensions.iter().any(|e| ext == *e) { |
| 236 | + scan_file_for_patterns(&path, patterns, max_file_size, endpoints); |
| 237 | + } |
| 238 | + } |
| 239 | + } else if path.is_dir() { |
| 240 | + // Skip common non-source directories |
| 241 | + let dir_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); |
| 242 | + if !["node_modules", ".git", "target", "build", "dist", "__pycache__", ".next", "vendor"].contains(&dir_name.as_str()) { |
| 243 | + scan_directory_for_patterns(&path, extensions, patterns, max_file_size, endpoints); |
| 244 | + } |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | +} |
| 249 | + |
| 250 | +/// Scan a single file for health route patterns |
| 251 | +fn scan_file_for_patterns( |
| 252 | + path: &Path, |
| 253 | + patterns: &[(&str, f32)], |
| 254 | + max_file_size: usize, |
| 255 | + endpoints: &mut Vec<HealthEndpoint>, |
| 256 | +) { |
| 257 | + if let Ok(content) = read_file_safe(path, max_file_size) { |
| 258 | + for (pattern, confidence) in patterns { |
| 259 | + if let Ok(regex) = Regex::new(pattern) { |
| 260 | + for cap in regex.captures_iter(&content) { |
| 261 | + if let Some(path_match) = cap.get(1) { |
| 262 | + let health_path = path_match.as_str().to_string(); |
| 263 | + // Only add if it looks like a health endpoint |
| 264 | + if COMMON_HEALTH_PATHS.iter().any(|p| health_path.contains(p) || p.contains(&health_path)) { |
| 265 | + if !endpoints.iter().any(|e| e.path == health_path) { |
| 266 | + endpoints.push(HealthEndpoint { |
| 267 | + path: health_path, |
| 268 | + confidence: *confidence, |
| 269 | + source: HealthEndpointSource::CodePattern, |
| 270 | + description: Some(format!("Found in {}", path.display())), |
| 271 | + }); |
| 272 | + } |
| 273 | + } |
| 274 | + } |
| 275 | + } |
| 276 | + } |
| 277 | + } |
| 278 | + } |
| 279 | +} |
| 280 | + |
| 281 | +/// JavaScript/TypeScript health route patterns |
| 282 | +fn js_health_patterns() -> Vec<(&'static str, f32)> { |
| 283 | + vec[^'"]*)['"]"#, 0.9), |
| 286 | + // NestJS style: @Get('health') |
| 287 | + (r#"@Get\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.9), |
| 288 | + // Hono/Elysia style: .get('/health', ...) |
| 289 | + (r#"\.get\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.9), |
| 290 | + ] |
| 291 | +} |
| 292 | + |
| 293 | +/// Python health route patterns |
| 294 | +fn python_health_patterns() -> Vec<(&'static str, f32)> { |
| 295 | + vec[^'"]*)['"]"#, 0.9), |
| 298 | + // Django URL patterns: path('health/', ...) |
| 299 | + (r#"path\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 0.85), |
| 300 | + ] |
| 301 | +} |
| 302 | + |
| 303 | +/// Go health route patterns |
| 304 | +fn go_health_patterns() -> Vec<(&'static str, f32)> { |
| 305 | + vec![ |
| 306 | + // http.HandleFunc("/health", ...) |
| 307 | + (r#"HandleFunc\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9), |
| 308 | + // Gin/Echo: r.GET("/health", ...) |
| 309 | + (r#"\.(?:GET|Handle)\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9), |
| 310 | + ] |
| 311 | +} |
| 312 | + |
| 313 | +/// Rust health route patterns |
| 314 | +fn rust_health_patterns() -> Vec<(&'static str, f32)> { |
| 315 | + vec![ |
| 316 | + // Actix: .route("/health", ...) |
| 317 | + (r#"\.route\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9), |
| 318 | + // Axum: .route("/health", get(...)) |
| 319 | + (r#"\.route\s*\(\s*"([^"]*(?:health|ready|live|status|ping)[^"]*)"#, 0.9), |
| 320 | + ] |
| 321 | +} |
| 322 | + |
| 323 | +/// Java health route patterns |
| 324 | +fn java_health_patterns() -> Vec<(&'static str, f32)> { |
| 325 | + vec[^"']*)["']"#, 0.9), |
| 328 | + ] |
| 329 | +} |
| 330 | + |
| 331 | +#[cfg(test)] |
| 332 | +mod tests { |
| 333 | + use super::*; |
| 334 | + |
| 335 | + #[test] |
| 336 | + fn test_spring_boot_health_endpoint() { |
| 337 | + let tech = DetectedTechnology { |
| 338 | + name: "Spring Boot".to_string(), |
| 339 | + version: None, |
| 340 | + category: TechnologyCategory::BackendFramework, |
| 341 | + confidence: 0.9, |
| 342 | + requires: vec![], |
| 343 | + conflicts_with: vec![], |
| 344 | + is_primary: true, |
| 345 | + file_indicators: vec![], |
| 346 | + }; |
| 347 | + |
| 348 | + let endpoint = get_framework_health_endpoint(&tech).unwrap(); |
| 349 | + assert_eq!(endpoint.path, "/actuator/health"); |
| 350 | + assert_eq!(endpoint.confidence, 0.7); |
| 351 | + } |
| 352 | + |
| 353 | + #[test] |
| 354 | + fn test_express_health_endpoint() { |
| 355 | + let tech = DetectedTechnology { |
| 356 | + name: "Express".to_string(), |
| 357 | + version: None, |
| 358 | + category: TechnologyCategory::BackendFramework, |
| 359 | + confidence: 0.9, |
| 360 | + requires: vec![], |
| 361 | + conflicts_with: vec![], |
| 362 | + is_primary: true, |
| 363 | + file_indicators: vec![], |
| 364 | + }; |
| 365 | + |
| 366 | + let endpoint = get_framework_health_endpoint(&tech).unwrap(); |
| 367 | + assert_eq!(endpoint.path, "/health"); |
| 368 | + assert_eq!(endpoint.confidence, 0.5); // Lower confidence for non-standard |
| 369 | + } |
| 370 | + |
| 371 | + #[test] |
| 372 | + fn test_unknown_framework_no_endpoint() { |
| 373 | + let tech = DetectedTechnology { |
| 374 | + name: "UnknownFramework".to_string(), |
| 375 | + version: None, |
| 376 | + category: TechnologyCategory::BackendFramework, |
| 377 | + confidence: 0.9, |
| 378 | + requires: vec![], |
| 379 | + conflicts_with: vec![], |
| 380 | + is_primary: true, |
| 381 | + file_indicators: vec![], |
| 382 | + }; |
| 383 | + |
| 384 | + assert!(get_framework_health_endpoint(&tech).is_none()); |
| 385 | + } |
| 386 | +} |
0 commit comments