syncable_cli/analyzer/context/
health_detector.rs

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
8use crate::analyzer::{DetectedTechnology, HealthEndpoint, HealthEndpointSource, TechnologyCategory};
9use crate::common::file_utils::{is_readable_file, read_file_safe};
10use crate::error::Result;
11use regex::Regex;
12use std::path::Path;
13
14/// Common health check paths to scan for
15const 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
30pub 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
67fn 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
121fn 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
223fn 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
251fn 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
282fn js_health_patterns() -> Vec<(&'static str, f32)> {
283    vec![
284        // Express/Fastify/Koa style: app.get('/health', ...)
285        (r#"\.(?:get|route)\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 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
294fn python_health_patterns() -> Vec<(&'static str, f32)> {
295    vec![
296        // FastAPI/Flask style: @app.get("/health")
297        (r#"@\w+\.(?:get|route)\s*\(\s*['"]([^'"]*(?:health|ready|live|status|ping)[^'"]*)['"]"#, 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
304fn 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
314fn 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
324fn java_health_patterns() -> Vec<(&'static str, f32)> {
325    vec![
326        // Spring: @GetMapping("/health")
327        (r#"@(?:Get|Request)Mapping\s*\(\s*(?:value\s*=\s*)?["']([^"']*(?:health|ready|live|status|ping)[^"']*)["']"#, 0.9),
328    ]
329}
330
331#[cfg(test)]
332mod 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}