1use crate::core::config::DockerConfig;
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ParsedService {
14 pub name: String,
15 pub container_name: Option<String>,
16}
17
18pub fn parse_compose_services(compose_content: &str) -> Vec<ParsedService> {
32 let mut services = Vec::new();
33 let mut in_services = false;
34 let mut services_indent = 0;
35 let mut current_service: Option<ParsedService> = None;
36 let mut service_indent_level: Option<usize> = None;
37
38 for line in compose_content.lines() {
39 let trimmed_end = line.trim_end();
40 if trimmed_end.is_empty() {
41 continue;
42 }
43 let trimmed = trimmed_end.trim();
44 if trimmed.starts_with('#') {
45 continue;
46 }
47
48 let indent = line.len() - line.trim_start().len();
49
50 if !in_services {
51 if trimmed == "services:" || trimmed.starts_with("services:") {
52 in_services = true;
53 services_indent = indent;
54 }
55 continue;
56 }
57
58 if indent <= services_indent && !trimmed.is_empty() {
60 in_services = false;
61 if let Some(svc) = current_service.take() {
62 services.push(svc);
63 }
64 continue;
65 }
66
67 if let Some(expected_level) = service_indent_level {
69 if indent == expected_level && trimmed.ends_with(':') {
70 if let Some(svc) = current_service.take() {
71 services.push(svc);
72 }
73 let name = trimmed
74 .trim_end_matches(':')
75 .trim()
76 .trim_matches(|c| c == '\'' || c == '"');
77 current_service = Some(ParsedService {
78 name: name.to_string(),
79 container_name: None,
80 });
81 continue;
82 } else if indent > expected_level {
83 if let Some(stripped) = trimmed.strip_prefix("container_name:") {
84 let val = stripped.trim().trim_matches(|c| c == '\'' || c == '"');
85 if let Some(ref mut svc) = current_service {
86 svc.container_name = Some(val.to_string());
87 }
88 }
89 continue;
90 }
91 }
92
93 if indent > services_indent && trimmed.ends_with(':') {
95 service_indent_level = Some(indent);
96 let name = trimmed
97 .trim_end_matches(':')
98 .trim()
99 .trim_matches(|c| c == '\'' || c == '"');
100 current_service = Some(ParsedService {
101 name: name.to_string(),
102 container_name: None,
103 });
104 }
105 }
106
107 if let Some(svc) = current_service {
108 services.push(svc);
109 }
110
111 services
112}
113
114fn parse_top_level_items(compose_content: &str, section_header: &str) -> Vec<String> {
138 let mut items = Vec::new();
139 let mut in_section = false;
140 let mut section_indent = 0;
141 let mut current_item: Option<String> = None;
142 let mut current_item_is_external = false;
143 let mut item_indent_level: Option<usize> = None;
144
145 for line in compose_content.lines() {
146 let trimmed_end = line.trim_end();
147 if trimmed_end.is_empty() {
148 continue;
149 }
150 let trimmed = trimmed_end.trim();
151 if trimmed.starts_with('#') {
152 continue;
153 }
154
155 let indent = line.len() - line.trim_start().len();
156
157 if !in_section {
158 if trimmed == section_header || trimmed.starts_with(section_header) {
159 in_section = true;
160 section_indent = indent;
161 }
162 continue;
163 }
164
165 if indent <= section_indent && !trimmed.is_empty() {
167 if let Some(item) = current_item.take() {
168 if !current_item_is_external {
169 items.push(item);
170 }
171 }
172 break;
173 }
174
175 if let Some(expected_level) = item_indent_level {
177 if indent == expected_level && trimmed.ends_with(':') {
178 if let Some(item) = current_item.take() {
179 if !current_item_is_external {
180 items.push(item);
181 }
182 }
183 let name = trimmed
184 .trim_end_matches(':')
185 .trim()
186 .trim_matches(|c| c == '\'' || c == '"');
187 current_item = Some(name.to_string());
188 current_item_is_external = false;
189 continue;
190 } else if indent > expected_level {
191 if trimmed.contains("external: true") || trimmed == "external: true" {
192 current_item_is_external = true;
193 }
194 continue;
195 }
196 }
197
198 if indent > section_indent && trimmed.ends_with(':') {
199 item_indent_level = Some(indent);
200 let name = trimmed
201 .trim_end_matches(':')
202 .trim()
203 .trim_matches(|c| c == '\'' || c == '"');
204 current_item = Some(name.to_string());
205 current_item_is_external = false;
206 }
207 }
208
209 if let Some(item) = current_item {
210 if !current_item_is_external {
211 items.push(item);
212 }
213 }
214
215 items
216}
217
218pub fn parse_compose_internal_networks(compose_content: &str) -> Vec<String> {
220 parse_top_level_items(compose_content, "networks:")
221}
222
223pub fn parse_compose_volumes(compose_content: &str) -> Vec<String> {
225 parse_top_level_items(compose_content, "volumes:")
226}
227
228pub fn generate_override_content_full(
230 services: &[ParsedService],
231 config: &DockerConfig,
232 extra_networks: &[String],
233 extra_volumes: &[String],
234) -> String {
235 let mut out = String::from("# Generated by git-claw\n");
236
237 let shared_set: BTreeSet<&str> = config.shared_services.iter().map(|s| s.as_str()).collect();
238
239 let mut service_lines = Vec::new();
240
241 for svc_name in &config.shared_services {
243 service_lines.push(format!(" {svc_name}:"));
244 service_lines.push(" container_name: !reset".to_string());
245 service_lines.push(" restart: \"no\"".to_string());
246 service_lines.push(" scale: 0".to_string());
247 service_lines.push(" entrypoint: [\"true\"]".to_string());
248 }
249
250 for svc in services {
252 if shared_set.contains(svc.name.as_str()) {
253 continue;
254 }
255
256 let needs_neutralization = svc.container_name.is_some();
257 let needs_network = config.network.is_some();
258
259 if needs_neutralization || needs_network {
260 service_lines.push(format!(" {}:", svc.name));
261 if needs_neutralization {
262 service_lines.push(" container_name: !reset".to_string());
263 }
264 if let Some(ref net) = config.network {
265 service_lines.push(" networks:".to_string());
266 service_lines.push(format!(" - {net}"));
267 }
268 }
269 }
270
271 if !service_lines.is_empty() {
272 out.push_str("services:\n");
273 for line in service_lines {
274 out.push_str(&line);
275 out.push('\n');
276 }
277 }
278
279 let mut all_networks = BTreeSet::new();
281 if let Some(ref net) = config.network {
282 all_networks.insert(net.clone());
283 }
284 for net in extra_networks {
285 all_networks.insert(net.clone());
286 }
287
288 if !all_networks.is_empty() {
289 out.push_str("networks:\n");
290 for net in &all_networks {
291 out.push_str(&format!(" {net}:\n"));
292 out.push_str(" external: true\n");
293 }
294 }
295
296 let mut all_volumes = BTreeSet::new();
298 for vol in extra_volumes {
299 all_volumes.insert(vol.clone());
300 }
301
302 if !all_volumes.is_empty() {
303 out.push_str("volumes:\n");
304 for vol in &all_volumes {
305 out.push_str(&format!(" {vol}:\n"));
306 out.push_str(" external: true\n");
307 }
308 }
309
310 out
311}
312
313pub fn generate_override_content(services: &[ParsedService], config: &DockerConfig) -> String {
335 generate_override_content_full(services, config, &[], &[])
336}
337
338pub fn generate_docker_compose_override(
360 toplevel_path: &Path,
361 worktree_path: &Path,
362 config: &DockerConfig,
363) -> Result<Option<PathBuf>, std::io::Error> {
364 if !config.is_enabled() {
365 return Ok(None);
366 }
367
368 let candidates = [
369 "docker-compose.yml",
370 "docker-compose.yaml",
371 "compose.yml",
372 "compose.yaml",
373 ];
374
375 let compose_path = if let Some(ref custom_file) = config.compose_file {
376 let p = worktree_path.join(custom_file);
377 if p.exists() {
378 Some(p)
379 } else {
380 let top_p = toplevel_path.join(custom_file);
381 if top_p.exists() {
382 Some(top_p)
383 } else {
384 None
385 }
386 }
387 } else {
388 candidates
389 .iter()
390 .map(|name| worktree_path.join(name))
391 .find(|p| p.exists())
392 .or_else(|| {
393 candidates
394 .iter()
395 .map(|name| toplevel_path.join(name))
396 .find(|p| p.exists())
397 })
398 };
399
400 let (services, extra_networks, extra_volumes) = if let Some(ref p) = compose_path {
401 let content = fs::read_to_string(p).unwrap_or_default();
402 let svcs = parse_compose_services(&content);
403 let nets = parse_compose_internal_networks(&content);
404 let vols = parse_compose_volumes(&content);
405 (svcs, nets, vols)
406 } else {
407 (Vec::new(), Vec::new(), Vec::new())
408 };
409
410 let content =
411 generate_override_content_full(&services, config, &extra_networks, &extra_volumes);
412 let target = worktree_path.join("docker-compose.override.yml");
413 fs::write(&target, &content)?;
414
415 if let Some(ref custom_file) = config.compose_file {
417 if let Some(parent) = Path::new(custom_file).parent() {
418 if parent != Path::new("") {
419 let sub_target = worktree_path
420 .join(parent)
421 .join("docker-compose.override.yml");
422 let _ = fs::write(sub_target, &content);
423 }
424 }
425 }
426
427 Ok(Some(target))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn test_parse_compose_networks_and_volumes() {
436 let yaml = r#"
437version: '3.8'
438
439services:
440 web:
441 image: web
442networks:
443 tune_network:
444 name: tune_network
445 external_net:
446 external: true
447volumes:
448 postgres_data:
449 name: tune_postgres_data
450 external_vol:
451 external: true
452"#;
453
454 let nets = parse_compose_internal_networks(yaml);
455 assert_eq!(nets, vec!["tune_network"]);
456
457 let vols = parse_compose_volumes(yaml);
458 assert_eq!(vols, vec!["postgres_data"]);
459 }
460
461 #[test]
462 fn test_generate_override_content_full_with_networks_and_volumes() {
463 let config = DockerConfig {
464 compose_file: None,
465 network: None,
466 shared_services: vec!["postgres".to_string()],
467 };
468 let services = vec![ParsedService {
469 name: "web".to_string(),
470 container_name: Some("web_app".to_string()),
471 }];
472 let nets = vec!["custom_network".to_string()];
473 let vols = vec!["custom_data".to_string()];
474
475 let content = generate_override_content_full(&services, &config, &nets, &vols);
476 assert!(content.contains("container_name: !reset"));
477 assert!(content.contains("networks:\n custom_network:\n external: true"));
478 assert!(content.contains("volumes:\n custom_data:\n external: true"));
479 }
480}