Skip to main content

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