Skip to main content

git_claw/infra/
docker.rs

1//! Docker Compose override generator and parser for worktree interoperability.
2//!
3//! Generates `docker-compose.claw.override.yml` to prevent container name
4//! collisions and configure shared service/network interop.
5
6use crate::core::config::DockerConfig;
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// A service parsed from a Compose YAML file.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ParsedService {
14    pub name: String,
15    pub container_name: Option<String>,
16}
17
18/// Parses service names and their container_name attributes from Compose YAML content.
19///
20/// # Example
21///
22/// ```
23/// use git_claw::infra::docker::parse_compose_services;
24///
25/// let yaml = "services:\n  web:\n    container_name: my_web\n";
26/// let services = parse_compose_services(yaml);
27/// assert_eq!(services.len(), 1);
28/// assert_eq!(services[0].name, "web");
29/// assert_eq!(services[0].container_name.as_deref(), Some("my_web"));
30/// ```
31pub 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        // Check if we exited the services block
59        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        // Inside services block:
68        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        // First service encountered
94        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
114/// Generates the content of `docker-compose.claw.override.yml`.
115///
116/// # Example
117///
118/// ```
119/// use git_claw::core::config::DockerConfig;
120/// use git_claw::infra::docker::{generate_override_content, ParsedService};
121///
122/// let config = DockerConfig {
123///     compose_file: None,
124///     network: Some("shared_net".to_string()),
125///     shared_services: vec!["db".to_string()],
126/// };
127/// let services = vec![ParsedService {
128///     name: "web".to_string(),
129///     container_name: Some("web_app".to_string()),
130/// }];
131/// let content = generate_override_content(&services, &config);
132/// assert!(content.contains("external: true"));
133/// assert!(content.contains("container_name: !reset"));
134/// ```
135/// Parses items under a top-level YAML section (e.g. `networks:` or `volumes:`).
136/// Excludes items that are already marked `external: true`.
137fn 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        // Check if we exited the section
166        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        // Inside section
176        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
218/// Parses network names defined under top-level `networks:` that are not already marked external.
219pub fn parse_compose_internal_networks(compose_content: &str) -> Vec<String> {
220    parse_top_level_items(compose_content, "networks:")
221}
222
223/// Parses volume names defined under top-level `volumes:` that are not already marked external.
224pub fn parse_compose_volumes(compose_content: &str) -> Vec<String> {
225    parse_top_level_items(compose_content, "volumes:")
226}
227
228/// Generates the content of `docker-compose.claw.override.yml`, including external networks and volumes.
229pub 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    // 1. Shared services: neutralized without breaking depends_on
242    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    // 2. Worktree-specific services that need container_name neutralization or network attachment
251    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    // Networks
280    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    // Volumes
297    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
313/// Generates the content of `docker-compose.claw.override.yml`.
314///
315/// # Example
316///
317/// ```
318/// use git_claw::core::config::DockerConfig;
319/// use git_claw::infra::docker::{generate_override_content, ParsedService};
320///
321/// let config = DockerConfig {
322///     compose_file: None,
323///     network: Some("shared_net".to_string()),
324///     shared_services: vec!["db".to_string()],
325/// };
326/// let services = vec![ParsedService {
327///     name: "web".to_string(),
328///     container_name: Some("web_app".to_string()),
329/// }];
330/// let content = generate_override_content(&services, &config);
331/// assert!(content.contains("external: true"));
332/// assert!(content.contains("container_name: !reset"));
333/// ```
334pub fn generate_override_content(services: &[ParsedService], config: &DockerConfig) -> String {
335    generate_override_content_full(services, config, &[], &[])
336}
337
338/// Generates `docker-compose.claw.override.yml` in the given worktree directory
339/// if Docker configuration is enabled.
340///
341/// Returns `Ok(Some(path))` if generated, or `Ok(None)` if Docker configuration is disabled.
342///
343/// # Example
344///
345/// ```
346/// use git_claw::core::config::DockerConfig;
347/// use git_claw::infra::docker::generate_docker_compose_override;
348/// use tempfile::tempdir;
349///
350/// let dir = tempdir().unwrap();
351/// let config = DockerConfig {
352///     compose_file: None,
353///     network: Some("net".to_string()),
354///     shared_services: vec!["postgres".to_string()],
355/// };
356/// let result = generate_docker_compose_override(dir.path(), dir.path(), &config).unwrap();
357/// assert!(result.is_some());
358/// ```
359pub 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 custom compose_file is in a subdirectory, also write it in that subdirectory
416    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}