syncable_cli/analyzer/context/
infra_detector.rs

1//! Infrastructure detection for deployment recommendations.
2//!
3//! Detects existing infrastructure configurations:
4//! - Kubernetes manifests (k8s/, deploy/, manifests/)
5//! - Helm charts (Chart.yaml)
6//! - Terraform files (*.tf)
7//! - Docker Compose files
8//! - Syncable deployment configs (.syncable/)
9
10use crate::analyzer::InfrastructurePresence;
11use crate::common::file_utils::is_readable_file;
12use std::path::{Path, PathBuf};
13
14/// Common directories where K8s manifests might be found
15const K8S_DIRECTORIES: &[&str] = &[
16    "k8s",
17    "kubernetes",
18    "deploy",
19    "deployment",
20    "deployments",
21    "manifests",
22    "kube",
23    "charts",
24    ".k8s",
25];
26
27/// Docker compose file variants
28const COMPOSE_FILES: &[&str] = &[
29    "docker-compose.yml",
30    "docker-compose.yaml",
31    "compose.yml",
32    "compose.yaml",
33    "docker-compose.dev.yml",
34    "docker-compose.prod.yml",
35    "docker-compose.local.yml",
36];
37
38/// Detect infrastructure presence in a project
39pub fn detect_infrastructure(project_root: &Path) -> InfrastructurePresence {
40    let mut infra = InfrastructurePresence::default();
41
42    // Detect Docker Compose
43    for compose_file in COMPOSE_FILES {
44        if is_readable_file(&project_root.join(compose_file)) {
45            infra.has_docker_compose = true;
46            break;
47        }
48    }
49
50    // Detect Kubernetes manifests
51    let k8s_paths = detect_kubernetes_manifests(project_root);
52    if !k8s_paths.is_empty() {
53        infra.has_kubernetes = true;
54        infra.kubernetes_paths = k8s_paths;
55    }
56
57    // Detect Helm charts
58    let helm_paths = detect_helm_charts(project_root);
59    if !helm_paths.is_empty() {
60        infra.has_helm = true;
61        infra.helm_chart_paths = helm_paths;
62    }
63
64    // Detect Terraform
65    let tf_paths = detect_terraform(project_root);
66    if !tf_paths.is_empty() {
67        infra.has_terraform = true;
68        infra.terraform_paths = tf_paths;
69    }
70
71    // Detect Syncable deployment config
72    infra.has_deployment_config = project_root.join(".syncable").is_dir()
73        || is_readable_file(&project_root.join("syncable.json"))
74        || is_readable_file(&project_root.join("syncable.yaml"))
75        || is_readable_file(&project_root.join("syncable.yml"));
76
77    // Generate summary
78    if infra.has_any() {
79        let types = infra.detected_types();
80        infra.summary = Some(format!("Detected: {}", types.join(", ")));
81    }
82
83    infra
84}
85
86/// Detect Kubernetes manifest directories and files
87fn detect_kubernetes_manifests(project_root: &Path) -> Vec<PathBuf> {
88    let mut paths = Vec::new();
89
90    // Check common K8s directories
91    for dir_name in K8S_DIRECTORIES {
92        let dir_path = project_root.join(dir_name);
93        if dir_path.is_dir() && has_kubernetes_files(&dir_path) {
94            paths.push(dir_path);
95        }
96    }
97
98    // Check root-level YAML files that might be K8s manifests
99    if let Ok(entries) = std::fs::read_dir(project_root) {
100        for entry in entries.flatten() {
101            let path = entry.path();
102            if path.is_file() {
103                if let Some(ext) = path.extension() {
104                    if (ext == "yaml" || ext == "yml") && is_kubernetes_manifest(&path) {
105                        paths.push(path);
106                    }
107                }
108            }
109        }
110    }
111
112    paths
113}
114
115/// Check if a directory contains Kubernetes files
116fn has_kubernetes_files(dir: &Path) -> bool {
117    if let Ok(entries) = std::fs::read_dir(dir) {
118        for entry in entries.flatten() {
119            let path = entry.path();
120            if path.is_file() {
121                if let Some(ext) = path.extension() {
122                    if (ext == "yaml" || ext == "yml") && is_kubernetes_manifest(&path) {
123                        return true;
124                    }
125                }
126            }
127        }
128    }
129    false
130}
131
132/// Check if a YAML file is a Kubernetes manifest (quick check without full parsing)
133fn is_kubernetes_manifest(path: &Path) -> bool {
134    if let Ok(content) = std::fs::read_to_string(path) {
135        // Check first 2KB of file for K8s markers (fast check)
136        let check_content = if content.len() > 2048 {
137            &content[..2048]
138        } else {
139            &content
140        };
141
142        // K8s manifest indicators
143        let k8s_kinds = [
144            "kind: Deployment",
145            "kind: Service",
146            "kind: Pod",
147            "kind: ConfigMap",
148            "kind: Secret",
149            "kind: Ingress",
150            "kind: StatefulSet",
151            "kind: DaemonSet",
152            "kind: Job",
153            "kind: CronJob",
154            "kind: PersistentVolumeClaim",
155            "kind: ServiceAccount",
156            "kind: Role",
157            "kind: RoleBinding",
158            "kind: ClusterRole",
159            "kind: ClusterRoleBinding",
160            "kind: NetworkPolicy",
161            "kind: HorizontalPodAutoscaler",
162            "kind: PodDisruptionBudget",
163            "kind: Namespace",
164        ];
165
166        // Check for apiVersion + kind pattern (most K8s manifests)
167        if check_content.contains("apiVersion:") {
168            for kind in &k8s_kinds {
169                if check_content.contains(*kind) {
170                    return true;
171                }
172            }
173        }
174    }
175    false
176}
177
178/// Detect Helm chart directories
179fn detect_helm_charts(project_root: &Path) -> Vec<PathBuf> {
180    let mut paths = Vec::new();
181
182    // Check if root is a Helm chart
183    if is_readable_file(&project_root.join("Chart.yaml")) {
184        paths.push(project_root.to_path_buf());
185    }
186
187    // Check common locations
188    let helm_locations = ["charts", "helm", "deploy/helm", "deployment/helm"];
189    for location in &helm_locations {
190        let dir = project_root.join(location);
191        if dir.is_dir() {
192            // Check if it's a chart itself
193            if is_readable_file(&dir.join("Chart.yaml")) {
194                paths.push(dir.clone());
195            }
196            // Check subdirectories for charts
197            if let Ok(entries) = std::fs::read_dir(&dir) {
198                for entry in entries.flatten() {
199                    let path = entry.path();
200                    if path.is_dir() && is_readable_file(&path.join("Chart.yaml")) {
201                        paths.push(path);
202                    }
203                }
204            }
205        }
206    }
207
208    paths
209}
210
211/// Detect Terraform directories
212fn detect_terraform(project_root: &Path) -> Vec<PathBuf> {
213    let mut paths = Vec::new();
214
215    // Check common Terraform locations
216    let tf_locations = ["terraform", "infra", "infrastructure", "tf", "iac"];
217    for location in &tf_locations {
218        let dir = project_root.join(location);
219        if dir.is_dir() && has_terraform_files(&dir) {
220            paths.push(dir);
221        }
222    }
223
224    // Check root for Terraform files
225    if has_terraform_files(project_root) {
226        paths.push(project_root.to_path_buf());
227    }
228
229    paths
230}
231
232/// Check if a directory contains Terraform files
233fn has_terraform_files(dir: &Path) -> bool {
234    if let Ok(entries) = std::fs::read_dir(dir) {
235        for entry in entries.flatten() {
236            let path = entry.path();
237            if path.is_file() {
238                if let Some(ext) = path.extension() {
239                    if ext == "tf" {
240                        return true;
241                    }
242                }
243            }
244        }
245    }
246    false
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::fs;
253    use tempfile::TempDir;
254
255    #[test]
256    fn test_detect_empty_project() {
257        let temp_dir = TempDir::new().unwrap();
258        let infra = detect_infrastructure(temp_dir.path());
259        assert!(!infra.has_any());
260    }
261
262    #[test]
263    fn test_detect_docker_compose() {
264        let temp_dir = TempDir::new().unwrap();
265        fs::write(temp_dir.path().join("docker-compose.yml"), "version: '3'\nservices:\n  app:\n    build: .").unwrap();
266
267        let infra = detect_infrastructure(temp_dir.path());
268        assert!(infra.has_docker_compose);
269        assert!(infra.has_any());
270    }
271
272    #[test]
273    fn test_detect_kubernetes_manifest() {
274        let temp_dir = TempDir::new().unwrap();
275        let k8s_dir = temp_dir.path().join("k8s");
276        fs::create_dir(&k8s_dir).unwrap();
277        fs::write(k8s_dir.join("deployment.yaml"), "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: test").unwrap();
278
279        let infra = detect_infrastructure(temp_dir.path());
280        assert!(infra.has_kubernetes);
281        assert_eq!(infra.kubernetes_paths.len(), 1);
282    }
283
284    #[test]
285    fn test_detect_helm_chart() {
286        let temp_dir = TempDir::new().unwrap();
287        let helm_dir = temp_dir.path().join("charts").join("myapp");
288        fs::create_dir_all(&helm_dir).unwrap();
289        fs::write(helm_dir.join("Chart.yaml"), "apiVersion: v2\nname: myapp\nversion: 1.0.0").unwrap();
290
291        let infra = detect_infrastructure(temp_dir.path());
292        assert!(infra.has_helm);
293        assert!(!infra.helm_chart_paths.is_empty());
294    }
295
296    #[test]
297    fn test_detect_terraform() {
298        let temp_dir = TempDir::new().unwrap();
299        let tf_dir = temp_dir.path().join("terraform");
300        fs::create_dir(&tf_dir).unwrap();
301        fs::write(tf_dir.join("main.tf"), "provider \"aws\" {\n  region = \"us-east-1\"\n}").unwrap();
302
303        let infra = detect_infrastructure(temp_dir.path());
304        assert!(infra.has_terraform);
305        assert!(!infra.terraform_paths.is_empty());
306    }
307
308    #[test]
309    fn test_detect_syncable_config() {
310        let temp_dir = TempDir::new().unwrap();
311        let syncable_dir = temp_dir.path().join(".syncable");
312        fs::create_dir(&syncable_dir).unwrap();
313
314        let infra = detect_infrastructure(temp_dir.path());
315        assert!(infra.has_deployment_config);
316    }
317
318    #[test]
319    fn test_infrastructure_summary() {
320        let temp_dir = TempDir::new().unwrap();
321        fs::write(temp_dir.path().join("docker-compose.yml"), "version: '3'").unwrap();
322        let tf_dir = temp_dir.path().join("terraform");
323        fs::create_dir(&tf_dir).unwrap();
324        fs::write(tf_dir.join("main.tf"), "provider \"aws\" {}").unwrap();
325
326        let infra = detect_infrastructure(temp_dir.path());
327        assert!(infra.has_docker_compose);
328        assert!(infra.has_terraform);
329        assert!(infra.summary.is_some());
330        let summary = infra.summary.unwrap();
331        assert!(summary.contains("Docker Compose"));
332        assert!(summary.contains("Terraform"));
333    }
334}