1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::Deserialize;
7
8use crate::config::{ServiceConfig, ServicesConfig};
9use crate::types::Replicas;
10
11#[derive(Debug, Deserialize)]
13pub struct ComposeFile {
14 #[serde(default)]
15 pub services: HashMap<String, ComposeService>,
16}
17
18#[derive(Debug, Deserialize)]
20pub struct ComposeService {
21 pub image: Option<String>,
22 #[serde(default)]
23 pub ports: Vec<String>,
24 #[serde(default)]
25 pub environment: ComposeEnv,
26 #[serde(default)]
27 pub volumes: Vec<String>,
28 #[serde(default)]
29 pub depends_on: ComposeDependsOn,
30}
31
32#[derive(Debug, Deserialize, Default)]
34#[serde(untagged)]
35pub enum ComposeEnv {
36 #[default]
37 None,
38 List(Vec<String>),
39 Map(HashMap<String, serde_yaml::Value>),
40}
41
42#[derive(Debug, Deserialize, Default)]
44#[serde(untagged)]
45pub enum ComposeDependsOn {
46 #[default]
47 None,
48 List(Vec<String>),
49 Map(HashMap<String, serde_yaml::Value>),
50}
51
52pub fn parse_compose_file(path: &Path) -> anyhow::Result<ServicesConfig> {
54 let content = std::fs::read_to_string(path)?;
55 parse_compose_str(&content, path)
56}
57
58pub fn parse_compose_str(content: &str, path: &Path) -> anyhow::Result<ServicesConfig> {
60 let compose: ComposeFile = serde_yaml::from_str(content)?;
61 let network_name = derive_network_name(path);
62 let services = compose
63 .services
64 .into_iter()
65 .map(|(name, svc)| convert_service(&name, &svc, &network_name))
66 .collect();
67 Ok(ServicesConfig { service: services })
68}
69
70fn derive_network_name(path: &Path) -> String {
72 path.parent()
73 .and_then(|p| p.file_name())
74 .and_then(|n| n.to_str())
75 .unwrap_or("default")
76 .to_string()
77}
78
79fn convert_service(name: &str, svc: &ComposeService, network: &str) -> ServiceConfig {
81 let (port, mounts_from_ports) = parse_ports(&svc.ports);
82 let env = parse_env(&svc.environment);
83 let aliases = vec![name.to_string()];
84
85 ServiceConfig {
86 name: name.to_string(),
87 project: None,
88 runtime: Default::default(),
89 image: svc.image.clone(),
90 module: None,
91 replicas: Replicas::Fixed(1),
92 port,
93 host_port: mounts_from_ports,
94 domain: None,
95 routes: vec![],
96 health: None,
97 readiness: None,
98 liveness: None,
99 env,
100 resources: None,
101 volume: None,
102 deploy: None,
103 placement: None,
104 network: Some(network.to_string()),
105 aliases,
106 mounts: svc.volumes.clone(),
107 triggers: vec![],
108 assets: None,
109 build: None,
110 tls_cert: None,
111 tls_key: None,
112 internal: false,
113 depends_on: vec![],
114 cmd: vec![],
115 extra_ports: vec![],
116 strip_prefix: None,
117 pull_policy: Default::default(),
118 }
119}
120
121fn parse_ports(ports: &[String]) -> (Option<u16>, Option<u16>) {
123 for port_str in ports {
124 let stripped = port_str.split('/').next().unwrap_or(port_str);
126 if let Some((host, container)) = stripped.split_once(':') {
127 let container_port = container.parse::<u16>().ok();
128 let host_port = host.parse::<u16>().ok();
129 if container_port.is_some() {
130 return (container_port, host_port);
131 }
132 } else if let Ok(p) = stripped.parse::<u16>() {
133 return (Some(p), None);
134 }
135 }
136 (None, None)
137}
138
139fn parse_env(env: &ComposeEnv) -> HashMap<String, String> {
141 match env {
142 ComposeEnv::None => HashMap::new(),
143 ComposeEnv::List(list) => list
144 .iter()
145 .filter_map(|entry| {
146 let (k, v) = entry.split_once('=')?;
147 Some((k.to_string(), v.to_string()))
148 })
149 .collect(),
150 ComposeEnv::Map(map) => map
151 .iter()
152 .map(|(k, v)| {
153 let val = match v {
154 serde_yaml::Value::String(s) => s.clone(),
155 serde_yaml::Value::Null => String::new(),
156 other => format!("{other:?}"),
157 };
158 (k.clone(), val)
159 })
160 .collect(),
161 }
162}
163
164pub fn services_to_toml(config: &ServicesConfig) -> anyhow::Result<String> {
166 Ok(toml::to_string_pretty(config)?)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn parse_simple_compose() {
175 let yaml = r#"
176services:
177 web:
178 image: nginx:latest
179 ports:
180 - "8080:80"
181 environment:
182 - FOO=bar
183 db:
184 image: postgres:16
185 ports:
186 - "5432:5432"
187 environment:
188 POSTGRES_PASSWORD: secret
189 volumes:
190 - pgdata:/var/lib/postgresql/data
191"#;
192 let path = Path::new("/tmp/myproject/docker-compose.yml");
193 let config = parse_compose_str(yaml, path).unwrap();
194 assert_eq!(config.service.len(), 2);
195
196 let web = config.service.iter().find(|s| s.name == "web").unwrap();
197 assert_eq!(web.image.as_deref(), Some("nginx:latest"));
198 assert_eq!(web.port, Some(80));
199 assert_eq!(web.host_port, Some(8080));
200 assert_eq!(web.env.get("FOO").map(String::as_str), Some("bar"));
201 assert_eq!(web.network.as_deref(), Some("myproject"));
202 assert!(web.aliases.contains(&"web".to_string()));
203
204 let db = config.service.iter().find(|s| s.name == "db").unwrap();
205 assert_eq!(db.image.as_deref(), Some("postgres:16"));
206 assert_eq!(db.port, Some(5432));
207 assert_eq!(
208 db.env.get("POSTGRES_PASSWORD").map(String::as_str),
209 Some("secret")
210 );
211 }
212
213 #[test]
214 fn parse_compose_no_ports() {
215 let yaml = r#"
216services:
217 worker:
218 image: myapp/worker:latest
219 environment:
220 - QUEUE=default
221"#;
222 let path = Path::new("/projects/app/docker-compose.yml");
223 let config = parse_compose_str(yaml, path).unwrap();
224 assert_eq!(config.service.len(), 1);
225 let worker = &config.service[0];
226 assert_eq!(worker.port, None);
227 assert_eq!(worker.host_port, None);
228 }
229
230 #[test]
231 fn services_to_toml_roundtrip() {
232 let config = ServicesConfig {
233 service: vec![ServiceConfig {
234 name: "test".to_string(),
235 project: None,
236 runtime: Default::default(),
237 image: Some("nginx:latest".to_string()),
238 module: None,
239 replicas: Replicas::Fixed(1),
240 port: Some(80),
241 host_port: None,
242 domain: None,
243 routes: vec![],
244 health: None,
245 readiness: None,
246 liveness: None,
247 env: HashMap::new(),
248 resources: None,
249 volume: None,
250 deploy: None,
251 placement: None,
252 network: Some("default".to_string()),
253 aliases: vec!["test".to_string()],
254 mounts: vec![],
255 triggers: vec![],
256 assets: None,
257 build: None,
258 tls_cert: None,
259 tls_key: None,
260 internal: false,
261 depends_on: vec![],
262 cmd: vec![],
263 extra_ports: vec![],
264 strip_prefix: None,
265 pull_policy: Default::default(),
266 }],
267 };
268 let toml_str = services_to_toml(&config).unwrap();
269 assert!(toml_str.contains("nginx:latest"));
270 }
271}