Skip to main content

git_claw/workflow/
init.rs

1//! Project initialization workflow (`git claw init`).
2//!
3//! Scans repository for environment files, compose files, and port variables,
4//! and generates a tailored `.git-claw.toml`.
5
6use 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
15/// Options for repository initialization.
16pub struct InitOptions<'a> {
17    pub yes: bool,
18    pub repo_root: Option<&'a Path>,
19}
20
21/// Recursively scans directory up to depth 3 for `.env` files.
22fn 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
67/// Scans for standard Compose file locations.
68fn 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
86/// Returns true if a key name is a client connection port to a database/message broker/mail service,
87/// which must NOT be shifted by slot offsets.
88fn 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
106/// Extracts port definitions from `.env` files.
107fn 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
141/// Scans Compose file for shared services and port definitions.
142fn 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    // Scan for port interpolation syntax in compose: ${PORT_VAR:-8000}
179    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
203/// Scans Compose file for defined network name.
204fn 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
273/// Generates tailored `.git-claw.toml` content.
274pub 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
322/// Runs the repository initialization workflow.
323///
324/// # Example
325///
326/// ```
327/// use git_claw::workflow::init::{run_init_workflow, InitOptions};
328/// use tempfile::tempdir;
329///
330/// let dir = tempdir().unwrap();
331/// let opts = InitOptions { yes: true, repo_root: Some(dir.path()) };
332/// let _ = run_init_workflow(opts);
333/// ```
334pub 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}