1use crate::cli::output::print_success;
7use crate::core::config::{DockerConfig, EnvironmentConfig};
8use crate::infra::docker::parse_compose_services;
9use crate::infra::git::resolve_toplevel;
10use crate::workflow::error::WorkflowError;
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15pub struct InitOptions<'a> {
17 pub yes: bool,
18 pub repo_root: Option<&'a Path>,
19}
20
21fn scan_env_files(root: &Path) -> Vec<String> {
23 let mut results = Vec::new();
24
25 fn visit_dirs(root: &Path, dir: &Path, depth: usize, results: &mut Vec<String>) {
26 if depth > 3 {
27 return;
28 }
29 let entries = match fs::read_dir(dir) {
30 Ok(e) => e,
31 Err(_) => return,
32 };
33
34 for entry in entries.flatten() {
35 let path = entry.path();
36 let file_name = entry.file_name();
37 let name_str = file_name.to_string_lossy();
38
39 if name_str.starts_with('.') && name_str != ".env" {
40 continue;
41 }
42 if name_str == "target" || name_str == "node_modules" || name_str == "vendor" {
43 continue;
44 }
45
46 if path.is_dir() {
47 if name_str == "media" || name_str == "uploads" || name_str == "storage" {
48 if let Ok(rel) = path.strip_prefix(root) {
49 results.push(rel.to_string_lossy().to_string());
50 }
51 } else {
52 visit_dirs(root, &path, depth + 1, results);
53 }
54 } else if path.is_file() && (name_str == ".env" || name_str.ends_with(".env")) {
55 if let Ok(rel) = path.strip_prefix(root) {
56 results.push(rel.to_string_lossy().to_string());
57 }
58 }
59 }
60 }
61
62 visit_dirs(root, root, 0, &mut results);
63 results.sort();
64 results
65}
66
67fn scan_compose_file(root: &Path) -> Option<String> {
69 let candidates = [
70 "docker-compose.yml",
71 "docker-compose.yaml",
72 "compose.yml",
73 "compose.yaml",
74 "deploy/docker-compose.yml",
75 "docker/docker-compose.yml",
76 ];
77
78 for c in &candidates {
79 if root.join(c).is_file() {
80 return Some((*c).to_string());
81 }
82 }
83 None
84}
85
86fn is_client_service_port(key: &str) -> bool {
89 let upper = key.to_ascii_uppercase();
90 upper.contains("DATABASE")
91 || upper.contains("DB_")
92 || upper.starts_with("DB")
93 || upper.contains("POSTGRES")
94 || upper.contains("PGPORT")
95 || upper.contains("MYSQL")
96 || upper.contains("MARIADB")
97 || upper.contains("MONGO")
98 || upper.contains("REDIS")
99 || upper.contains("EMAIL")
100 || upper.contains("SMTP")
101 || upper.contains("MAIL")
102 || upper.contains("AMQP")
103 || upper.contains("RABBITMQ")
104}
105
106fn extract_ports_from_env_files(root: &Path, env_files: &[String]) -> BTreeMap<String, u16> {
108 let mut ports = BTreeMap::new();
109
110 for rel_path in env_files {
111 let full_path = root.join(rel_path);
112 let content = match fs::read_to_string(&full_path) {
113 Ok(c) => c,
114 Err(_) => continue,
115 };
116
117 for line in content.lines() {
118 let trimmed = line.trim();
119 if trimmed.is_empty() || trimmed.starts_with('#') {
120 continue;
121 }
122
123 if let Some((key, val)) = trimmed.split_once('=') {
124 let key = key.trim();
125 let val = val.trim().trim_matches(|c| c == '\'' || c == '"');
126
127 if (key == "PORT" || key.contains("PORT")) && !is_client_service_port(key) {
128 if let Ok(port) = val.parse::<u16>() {
129 if port > 0 {
130 ports.insert(key.to_string(), port);
131 }
132 }
133 }
134 }
135 }
136 }
137
138 ports
139}
140
141fn scan_compose_details(
143 root: &Path,
144 compose_file: &str,
145 ports: &mut BTreeMap<String, u16>,
146) -> DockerConfig {
147 let mut docker = DockerConfig {
148 compose_file: Some(compose_file.to_string()),
149 network: None,
150 shared_services: Vec::new(),
151 };
152
153 let content = match fs::read_to_string(root.join(compose_file)) {
154 Ok(c) => c,
155 Err(_) => return docker,
156 };
157
158 let services = parse_compose_services(&content);
159 let common_shared = [
160 "postgres",
161 "postgresql",
162 "db",
163 "database",
164 "redis",
165 "mysql",
166 "mariadb",
167 "mongo",
168 "mongodb",
169 "rabbitmq",
170 ];
171
172 for svc in services {
173 if common_shared.contains(&svc.name.as_str()) {
174 docker.shared_services.push(svc.name);
175 }
176 }
177
178 for line in content.lines() {
180 if let Some(start_idx) = line.find("${") {
181 let rest = &line[start_idx + 2..];
182 if let Some(end_idx) = rest.find('}') {
183 let var_expr = &rest[..end_idx];
184 if let Some((var_name, default_val)) = var_expr.split_once(":-") {
185 if (var_name == "PORT" || var_name.contains("PORT"))
186 && !is_client_service_port(var_name)
187 && !ports.contains_key(var_name)
188 {
189 if let Ok(port) = default_val.parse::<u16>() {
190 ports.insert(var_name.to_string(), port);
191 }
192 }
193 }
194 }
195 }
196 }
197
198 docker.network = scan_compose_network(&content);
199
200 docker
201}
202
203fn scan_compose_network(content: &str) -> Option<String> {
205 let mut in_networks = false;
206 let mut networks_indent = 0;
207 let mut current_net: Option<String> = None;
208 let mut net_indent_level: Option<usize> = None;
209
210 for line in content.lines() {
211 let trimmed_end = line.trim_end();
212 if trimmed_end.is_empty() || trimmed_end.trim_start().starts_with('#') {
213 continue;
214 }
215 let indent = line.len() - line.trim_start().len();
216 let trimmed = trimmed_end.trim();
217
218 if !in_networks {
219 if trimmed == "networks:" || trimmed.starts_with("networks:") {
220 in_networks = true;
221 networks_indent = indent;
222 }
223 continue;
224 }
225
226 if indent <= networks_indent && !trimmed.is_empty() {
227 break;
228 }
229
230 if let Some(expected_level) = net_indent_level {
231 if indent == expected_level && trimmed.ends_with(':') {
232 if let Some(net) = current_net {
233 if net != "default" {
234 return Some(net);
235 }
236 }
237 let name = trimmed
238 .trim_end_matches(':')
239 .trim()
240 .trim_matches(|c| c == '\'' || c == '"');
241 current_net = Some(name.to_string());
242 continue;
243 } else if indent > expected_level {
244 if let Some(stripped) = trimmed.strip_prefix("name:") {
245 let val = stripped.trim().trim_matches(|c| c == '\'' || c == '"');
246 if !val.is_empty() {
247 return Some(val.to_string());
248 }
249 }
250 continue;
251 }
252 }
253
254 if indent > networks_indent && trimmed.ends_with(':') {
255 net_indent_level = Some(indent);
256 let name = trimmed
257 .trim_end_matches(':')
258 .trim()
259 .trim_matches(|c| c == '\'' || c == '"');
260 current_net = Some(name.to_string());
261 }
262 }
263
264 if let Some(net) = current_net {
265 if net != "default" {
266 return Some(net);
267 }
268 }
269
270 None
271}
272
273pub fn scan_environment(root: &Path) -> (EnvironmentConfig, Option<String>) {
294 let mut env = EnvironmentConfig::default();
295
296 let py_version_file = root.join(".python-version");
297 if py_version_file.is_file() {
298 env.r#type = Some("pyenv".to_string());
299 if let Ok(content) = fs::read_to_string(&py_version_file) {
300 let trimmed = content.trim();
301 if !trimmed.is_empty() {
302 env.name = Some(trimmed.to_string());
303 }
304 }
305 } else if root.join("uv.lock").is_file() {
306 env.r#type = Some("uv".to_string());
307 } else if root.join(".venv").is_dir() {
308 env.r#type = Some("venv".to_string());
309 }
310
311 let standard_files = [".python-version", ".node-version", ".nvmrc", ".envrc"];
312 for file in &standard_files {
313 if root.join(file).is_file() {
314 env.copy_files.push((*file).to_string());
315 }
316 }
317
318 let cache_dir = if root.join(".venv").is_dir() || root.join("uv.lock").is_file() {
319 Some(".venv".to_string())
320 } else {
321 None
322 };
323
324 (env, cache_dir)
325}
326
327pub fn generate_init_toml(
349 main_branch: &str,
350 ports: &BTreeMap<String, u16>,
351 files: &[String],
352 docker: &DockerConfig,
353 environment: &EnvironmentConfig,
354 cache_dirs: &[String],
355) -> String {
356 let mut toml = String::new();
357 toml.push_str("[project]\n");
358 toml.push_str(&format!("main_branch = \"{}\"\n", main_branch));
359
360 if !ports.is_empty() {
361 toml.push_str("\n[ports]\n");
362 for (k, v) in ports {
363 toml.push_str(&format!("{} = {}\n", k, v));
364 }
365 }
366
367 if !files.is_empty() {
368 toml.push_str("\n[files]\n");
369 toml.push_str("copy = [\n");
370 for f in files {
371 toml.push_str(&format!(" \"{}\",\n", f));
372 }
373 toml.push_str("]\n");
374 }
375
376 let has_env = environment.r#type.is_some()
377 || environment.name.is_some()
378 || !environment.copy_files.is_empty();
379
380 if has_env {
381 toml.push_str("\n[environment]\n");
382 if let Some(ref t) = environment.r#type {
383 toml.push_str(&format!("type = \"{}\"\n", t));
384 }
385 if let Some(ref n) = environment.name {
386 toml.push_str(&format!("name = \"{}\"\n", n));
387 }
388 if !environment.copy_files.is_empty() {
389 let files_str = environment
390 .copy_files
391 .iter()
392 .map(|f| format!("\"{}\"", f))
393 .collect::<Vec<_>>()
394 .join(", ");
395 toml.push_str(&format!("copy_files = [{}]\n", files_str));
396 }
397 }
398
399 if !cache_dirs.is_empty() {
400 toml.push_str("\n[cache]\n");
401 toml.push_str("directories = [\n");
402 for d in cache_dirs {
403 toml.push_str(&format!(" \"{}\",\n", d));
404 }
405 toml.push_str("]\n");
406 }
407
408 if docker.is_enabled() {
409 toml.push_str("\n[docker]\n");
410 if let Some(ref cf) = docker.compose_file {
411 toml.push_str(&format!("compose_file = \"{}\"\n", cf));
412 }
413 if let Some(ref net) = docker.network {
414 toml.push_str(&format!("network = \"{}\"\n", net));
415 }
416 if !docker.shared_services.is_empty() {
417 let svcs = docker
418 .shared_services
419 .iter()
420 .map(|s| format!("\"{}\"", s))
421 .collect::<Vec<_>>()
422 .join(", ");
423 toml.push_str(&format!("shared_services = [{}]\n", svcs));
424 }
425 }
426
427 toml
428}
429
430pub fn run_init_workflow(options: InitOptions) -> Result<PathBuf, WorkflowError> {
443 let root = resolve_toplevel(options.repo_root)?;
444 let target = root.join(".git-claw.toml");
445
446 let env_files = scan_env_files(&root);
447 let mut ports = extract_ports_from_env_files(&root, &env_files);
448
449 let (environment, cache_dir) = scan_environment(&root);
450 let cache_dirs = cache_dir.map(|c| vec![c]).unwrap_or_default();
451
452 let docker = if let Some(compose_file) = scan_compose_file(&root) {
453 scan_compose_details(&root, &compose_file, &mut ports)
454 } else {
455 DockerConfig::default()
456 };
457
458 let content = generate_init_toml(
459 "main",
460 &ports,
461 &env_files,
462 &docker,
463 &environment,
464 &cache_dirs,
465 );
466 fs::write(&target, content)?;
467
468 print_success(format!(
469 "Initialized .git-claw.toml configuration at {}",
470 target.display()
471 ));
472
473 Ok(target)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use tempfile::tempdir;
480
481 #[test]
482 fn test_scan_environment_pyenv_and_venv() {
483 let dir = tempdir().unwrap();
484 fs::write(dir.path().join(".python-version"), "tune\n").unwrap();
485 fs::create_dir_all(dir.path().join(".venv")).unwrap();
486
487 let (env, cache_dir) = scan_environment(dir.path());
488 assert_eq!(env.r#type.as_deref(), Some("pyenv"));
489 assert_eq!(env.name.as_deref(), Some("tune"));
490 assert!(env.copy_files.contains(&".python-version".to_string()));
491 assert_eq!(cache_dir.as_deref(), Some(".venv"));
492 }
493
494 #[test]
495 fn test_scan_environment_uv() {
496 let dir = tempdir().unwrap();
497 fs::write(dir.path().join("uv.lock"), "# lockfile").unwrap();
498
499 let (env, cache_dir) = scan_environment(dir.path());
500 assert_eq!(env.r#type.as_deref(), Some("uv"));
501 assert_eq!(env.name, None);
502 assert_eq!(cache_dir.as_deref(), Some(".venv"));
503 }
504
505 #[test]
506 fn test_scan_environment_node_files() {
507 let dir = tempdir().unwrap();
508 fs::write(dir.path().join(".nvmrc"), "v20.0.0\n").unwrap();
509 fs::write(dir.path().join(".node-version"), "20\n").unwrap();
510 fs::write(dir.path().join(".envrc"), "use flake\n").unwrap();
511
512 let (env, cache_dir) = scan_environment(dir.path());
513 assert_eq!(env.r#type, None);
514 assert!(env.copy_files.contains(&".nvmrc".to_string()));
515 assert!(env.copy_files.contains(&".node-version".to_string()));
516 assert!(env.copy_files.contains(&".envrc".to_string()));
517 assert_eq!(cache_dir, None);
518 }
519}