syncable_cli/analyzer/context/
infra_detector.rs1use crate::analyzer::InfrastructurePresence;
11use crate::common::file_utils::is_readable_file;
12use std::path::{Path, PathBuf};
13
14const K8S_DIRECTORIES: &[&str] = &[
16 "k8s",
17 "kubernetes",
18 "deploy",
19 "deployment",
20 "deployments",
21 "manifests",
22 "kube",
23 "charts",
24 ".k8s",
25];
26
27const 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
38pub fn detect_infrastructure(project_root: &Path) -> InfrastructurePresence {
40 let mut infra = InfrastructurePresence::default();
41
42 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 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 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 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 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 if infra.has_any() {
79 let types = infra.detected_types();
80 infra.summary = Some(format!("Detected: {}", types.join(", ")));
81 }
82
83 infra
84}
85
86fn detect_kubernetes_manifests(project_root: &Path) -> Vec<PathBuf> {
88 let mut paths = Vec::new();
89
90 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 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
115fn 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
132fn is_kubernetes_manifest(path: &Path) -> bool {
134 if let Ok(content) = std::fs::read_to_string(path) {
135 let check_content = if content.len() > 2048 {
137 &content[..2048]
138 } else {
139 &content
140 };
141
142 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 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
178fn detect_helm_charts(project_root: &Path) -> Vec<PathBuf> {
180 let mut paths = Vec::new();
181
182 if is_readable_file(&project_root.join("Chart.yaml")) {
184 paths.push(project_root.to_path_buf());
185 }
186
187 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 if is_readable_file(&dir.join("Chart.yaml")) {
194 paths.push(dir.clone());
195 }
196 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
211fn detect_terraform(project_root: &Path) -> Vec<PathBuf> {
213 let mut paths = Vec::new();
214
215 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 if has_terraform_files(project_root) {
226 paths.push(project_root.to_path_buf());
227 }
228
229 paths
230}
231
232fn 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}