git_claw/workflow/
init.rs1use crate::cli::output::print_success;
7use crate::core::config::DockerConfig;
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 generate_init_toml(
275 main_branch: &str,
276 ports: &BTreeMap<String, u16>,
277 files: &[String],
278 docker: &DockerConfig,
279) -> String {
280 let mut toml = String::new();
281 toml.push_str("[project]\n");
282 toml.push_str(&format!("main_branch = \"{}\"\n", main_branch));
283
284 if !ports.is_empty() {
285 toml.push_str("\n[ports]\n");
286 for (k, v) in ports {
287 toml.push_str(&format!("{} = {}\n", k, v));
288 }
289 }
290
291 if !files.is_empty() {
292 toml.push_str("\n[files]\n");
293 toml.push_str("copy = [\n");
294 for f in files {
295 toml.push_str(&format!(" \"{}\",\n", f));
296 }
297 toml.push_str("]\n");
298 }
299
300 if docker.is_enabled() {
301 toml.push_str("\n[docker]\n");
302 if let Some(ref cf) = docker.compose_file {
303 toml.push_str(&format!("compose_file = \"{}\"\n", cf));
304 }
305 if let Some(ref net) = docker.network {
306 toml.push_str(&format!("network = \"{}\"\n", net));
307 }
308 if !docker.shared_services.is_empty() {
309 let svcs = docker
310 .shared_services
311 .iter()
312 .map(|s| format!("\"{}\"", s))
313 .collect::<Vec<_>>()
314 .join(", ");
315 toml.push_str(&format!("shared_services = [{}]\n", svcs));
316 }
317 }
318
319 toml
320}
321
322pub fn run_init_workflow(options: InitOptions) -> Result<PathBuf, WorkflowError> {
335 let root = resolve_toplevel(options.repo_root)?;
336 let target = root.join(".git-claw.toml");
337
338 let env_files = scan_env_files(&root);
339 let mut ports = extract_ports_from_env_files(&root, &env_files);
340
341 let docker = if let Some(compose_file) = scan_compose_file(&root) {
342 scan_compose_details(&root, &compose_file, &mut ports)
343 } else {
344 DockerConfig::default()
345 };
346
347 let content = generate_init_toml("main", &ports, &env_files, &docker);
348 fs::write(&target, content)?;
349
350 print_success(format!(
351 "Initialized .git-claw.toml configuration at {}",
352 target.display()
353 ));
354
355 Ok(target)
356}