1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ComposeConfig {
31 #[serde(default)]
33 pub version: Option<String>,
34
35 pub services: HashMap<String, ServiceConfig>,
37
38 #[serde(default)]
40 pub volumes: HashMap<String, Option<VolumeDeclaration>>,
41
42 #[serde(default)]
44 pub networks: HashMap<String, Option<NetworkDeclaration>>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct ServiceConfig {
50 #[serde(default)]
52 pub image: Option<String>,
53
54 #[serde(default)]
56 pub entrypoint: Option<StringOrList>,
57
58 #[serde(default)]
60 pub command: Option<StringOrList>,
61
62 #[serde(default)]
64 pub environment: EnvVars,
65
66 #[serde(default)]
68 pub ports: Vec<String>,
69
70 #[serde(default)]
72 pub volumes: Vec<String>,
73
74 #[serde(default)]
76 pub depends_on: DependsOn,
77
78 #[serde(default)]
80 pub networks: ServiceNetworks,
81
82 #[serde(default)]
84 pub cpus: Option<u32>,
85
86 #[serde(default)]
88 pub mem_limit: Option<String>,
89
90 #[serde(default)]
92 pub restart: Option<String>,
93
94 #[serde(default)]
96 pub dns: DnsConfig,
97
98 #[serde(default)]
100 pub tmpfs: StringOrList,
101
102 #[serde(default)]
104 pub cap_add: Vec<String>,
105
106 #[serde(default)]
108 pub cap_drop: Vec<String>,
109
110 #[serde(default)]
112 pub privileged: bool,
113
114 #[serde(default)]
116 pub labels: Labels,
117
118 #[serde(default)]
120 pub healthcheck: Option<HealthcheckConfig>,
121
122 #[serde(default)]
124 pub working_dir: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct HealthcheckConfig {
130 pub test: StringOrList,
132 #[serde(default)]
134 pub interval: Option<String>,
135 #[serde(default)]
137 pub timeout: Option<String>,
138 #[serde(default)]
140 pub retries: Option<u32>,
141 #[serde(default)]
143 pub start_period: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub struct VolumeDeclaration {
149 #[serde(default)]
151 pub driver: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156pub struct NetworkDeclaration {
157 #[serde(default)]
159 pub driver: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166#[serde(untagged)]
167pub enum StringOrList {
168 #[default]
169 Empty,
170 Single(String),
171 List(Vec<String>),
172}
173
174impl StringOrList {
175 pub fn to_vec(&self) -> Vec<String> {
177 match self {
178 Self::Empty => vec![],
179 Self::Single(s) => s.split_whitespace().map(String::from).collect(),
180 Self::List(v) => v.clone(),
181 }
182 }
183
184 pub fn is_empty(&self) -> bool {
186 match self {
187 Self::Empty => true,
188 Self::Single(s) => s.is_empty(),
189 Self::List(v) => v.is_empty(),
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199#[serde(untagged)]
200pub enum EnvVars {
201 #[default]
202 Empty,
203 Map(HashMap<String, String>),
204 List(Vec<String>),
205}
206
207impl EnvVars {
208 pub fn to_pairs(&self) -> Vec<(String, String)> {
210 match self {
211 Self::Empty => vec![],
212 Self::Map(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
213 Self::List(list) => list
214 .iter()
215 .filter_map(|s| {
216 let (k, v) = s.split_once('=')?;
217 Some((k.to_string(), v.to_string()))
218 })
219 .collect(),
220 }
221 }
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, Default)]
229#[serde(untagged)]
230pub enum DependsOn {
231 #[default]
232 Empty,
233 List(Vec<String>),
234 Map(HashMap<String, DependsOnCondition>),
235}
236
237impl DependsOn {
238 pub fn services(&self) -> Vec<String> {
240 match self {
241 Self::Empty => vec![],
242 Self::List(v) => v.clone(),
243 Self::Map(m) => m.keys().cloned().collect(),
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DependsOnCondition {
251 #[serde(default = "default_condition")]
253 pub condition: String,
254}
255
256fn default_condition() -> String {
257 "service_started".to_string()
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
265#[serde(untagged)]
266pub enum ServiceNetworks {
267 #[default]
268 Empty,
269 List(Vec<String>),
270 Map(HashMap<String, Option<ServiceNetworkConfig>>),
271}
272
273impl ServiceNetworks {
274 pub fn names(&self) -> Vec<String> {
276 match self {
277 Self::Empty => vec![],
278 Self::List(v) => v.clone(),
279 Self::Map(m) => m.keys().cloned().collect(),
280 }
281 }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, Default)]
286pub struct ServiceNetworkConfig {
287 #[serde(default)]
289 pub aliases: Vec<String>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, Default)]
294#[serde(untagged)]
295pub enum Labels {
296 #[default]
297 Empty,
298 Map(HashMap<String, String>),
299 List(Vec<String>),
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, Default)]
304#[serde(untagged)]
305pub enum DnsConfig {
306 #[default]
307 Empty,
308 Single(String),
309 List(Vec<String>),
310}
311
312impl DnsConfig {
313 pub fn to_vec(&self) -> Vec<String> {
315 match self {
316 Self::Empty => vec![],
317 Self::Single(s) => vec![s.clone()],
318 Self::List(v) => v.clone(),
319 }
320 }
321}
322
323impl ComposeConfig {
324 pub fn from_yaml(yaml: &[u8]) -> Result<Self, serde_yaml::Error> {
326 serde_yaml::from_slice(yaml)
327 }
328
329 pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml::Error> {
331 serde_yaml::from_str(yaml)
332 }
333
334 pub fn service_order(&self) -> Result<Vec<String>, String> {
338 let mut order = Vec::new();
339 let mut state: HashMap<String, u8> = HashMap::new();
341
342 for name in self.services.keys() {
343 if !state.contains_key(name) {
344 self.topo_visit(name, &mut state, &mut order)?;
345 }
346 }
347
348 Ok(order)
349 }
350
351 fn topo_visit(
352 &self,
353 name: &str,
354 state: &mut HashMap<String, u8>,
355 order: &mut Vec<String>,
356 ) -> Result<(), String> {
357 match state.get(name) {
358 Some(1) => {
359 return Err(format!(
360 "Dependency cycle detected involving service '{}'",
361 name
362 ));
363 }
364 Some(2) => return Ok(()), _ => {}
366 }
367
368 state.insert(name.to_string(), 1); if let Some(svc) = self.services.get(name) {
371 let deps = svc.depends_on.services();
372 for dep in &deps {
373 if !self.services.contains_key(dep) {
374 return Err(format!(
375 "Service '{}' depends on '{}' which is not defined",
376 name, dep
377 ));
378 }
379 self.topo_visit(dep, state, order)?;
380 }
381 }
382
383 state.insert(name.to_string(), 2); order.push(name.to_string());
385 Ok(())
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_parse_minimal_compose() {
395 let yaml = r#"
396services:
397 web:
398 image: nginx:latest
399"#;
400 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
401 assert_eq!(config.services.len(), 1);
402 assert_eq!(
403 config.services["web"].image.as_deref(),
404 Some("nginx:latest")
405 );
406 }
407
408 #[test]
409 fn test_parse_full_compose() {
410 let yaml = r#"
411version: "3"
412services:
413 web:
414 image: nginx:latest
415 ports:
416 - "8080:80"
417 depends_on:
418 - db
419 environment:
420 APP_ENV: production
421 volumes:
422 - "static:/usr/share/nginx/html"
423 db:
424 image: postgres:16
425 environment:
426 - POSTGRES_PASSWORD=secret
427 volumes:
428 - "pgdata:/var/lib/postgresql/data"
429 mem_limit: "1g"
430 cpus: 2
431volumes:
432 pgdata:
433 static:
434networks:
435 default:
436"#;
437 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
438 assert_eq!(config.services.len(), 2);
439 assert_eq!(config.volumes.len(), 2);
440 assert!(config.services["web"]
441 .depends_on
442 .services()
443 .contains(&"db".to_string()));
444 assert_eq!(config.services["web"].ports, vec!["8080:80"]);
445 assert_eq!(config.services["db"].cpus, Some(2));
446 assert_eq!(config.services["db"].mem_limit.as_deref(), Some("1g"));
447 }
448
449 #[test]
450 fn test_service_order_simple() {
451 let yaml = r#"
452services:
453 web:
454 image: nginx
455 depends_on: [api]
456 api:
457 image: myapi
458 depends_on: [db]
459 db:
460 image: postgres
461"#;
462 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
463 let order = config.service_order().unwrap();
464 let db_pos = order.iter().position(|s| s == "db").unwrap();
465 let api_pos = order.iter().position(|s| s == "api").unwrap();
466 let web_pos = order.iter().position(|s| s == "web").unwrap();
467 assert!(db_pos < api_pos);
468 assert!(api_pos < web_pos);
469 }
470
471 #[test]
472 fn test_service_order_cycle_detected() {
473 let yaml = r#"
474services:
475 a:
476 image: img
477 depends_on: [b]
478 b:
479 image: img
480 depends_on: [a]
481"#;
482 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
483 let result = config.service_order();
484 assert!(result.is_err());
485 assert!(result.unwrap_err().contains("cycle"));
486 }
487
488 #[test]
489 fn test_service_order_missing_dependency() {
490 let yaml = r#"
491services:
492 web:
493 image: nginx
494 depends_on: [nonexistent]
495"#;
496 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
497 let result = config.service_order();
498 assert!(result.is_err());
499 assert!(result.unwrap_err().contains("not defined"));
500 }
501
502 #[test]
503 fn test_service_order_no_deps() {
504 let yaml = r#"
505services:
506 a:
507 image: img
508 b:
509 image: img
510 c:
511 image: img
512"#;
513 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
514 let order = config.service_order().unwrap();
515 assert_eq!(order.len(), 3);
516 }
517
518 #[test]
519 fn test_env_vars_map() {
520 let yaml = r#"
521services:
522 web:
523 image: nginx
524 environment:
525 KEY1: val1
526 KEY2: val2
527"#;
528 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
529 let pairs = config.services["web"].environment.to_pairs();
530 assert_eq!(pairs.len(), 2);
531 }
532
533 #[test]
534 fn test_env_vars_list() {
535 let yaml = r#"
536services:
537 web:
538 image: nginx
539 environment:
540 - KEY1=val1
541 - KEY2=val2
542"#;
543 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
544 let pairs = config.services["web"].environment.to_pairs();
545 assert_eq!(pairs.len(), 2);
546 assert!(pairs.iter().any(|(k, v)| k == "KEY1" && v == "val1"));
547 }
548
549 #[test]
550 fn test_string_or_list_single() {
551 let sol = StringOrList::Single("echo hello world".to_string());
552 assert_eq!(sol.to_vec(), vec!["echo", "hello", "world"]);
553 assert!(!sol.is_empty());
554 }
555
556 #[test]
557 fn test_string_or_list_list() {
558 let sol = StringOrList::List(vec!["echo".into(), "hello world".into()]);
559 assert_eq!(sol.to_vec(), vec!["echo", "hello world"]);
560 }
561
562 #[test]
563 fn test_string_or_list_empty() {
564 let sol = StringOrList::Empty;
565 assert!(sol.is_empty());
566 assert!(sol.to_vec().is_empty());
567 }
568
569 #[test]
570 fn test_depends_on_list() {
571 let yaml = r#"
572services:
573 web:
574 image: nginx
575 depends_on:
576 - db
577 - redis
578"#;
579 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
580 let deps = config.services["web"].depends_on.services();
581 assert_eq!(deps.len(), 2);
582 assert!(deps.contains(&"db".to_string()));
583 assert!(deps.contains(&"redis".to_string()));
584 }
585
586 #[test]
587 fn test_depends_on_map() {
588 let yaml = r#"
589services:
590 web:
591 image: nginx
592 depends_on:
593 db:
594 condition: service_healthy
595"#;
596 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
597 let deps = config.services["web"].depends_on.services();
598 assert_eq!(deps, vec!["db"]);
599 }
600
601 #[test]
602 fn test_dns_config_single() {
603 let dns = DnsConfig::Single("8.8.8.8".to_string());
604 assert_eq!(dns.to_vec(), vec!["8.8.8.8"]);
605 }
606
607 #[test]
608 fn test_dns_config_list() {
609 let dns = DnsConfig::List(vec!["8.8.8.8".into(), "1.1.1.1".into()]);
610 assert_eq!(dns.to_vec().len(), 2);
611 }
612
613 #[test]
614 fn test_service_networks_list() {
615 let yaml = r#"
616services:
617 web:
618 image: nginx
619 networks:
620 - frontend
621 - backend
622"#;
623 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
624 let nets = config.services["web"].networks.names();
625 assert_eq!(nets.len(), 2);
626 }
627
628 #[test]
629 fn test_healthcheck_config() {
630 let yaml = r#"
631services:
632 web:
633 image: nginx
634 healthcheck:
635 test: ["CMD", "curl", "-f", "http://localhost/"]
636 interval: "30s"
637 timeout: "5s"
638 retries: 3
639"#;
640 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
641 let hc = config.services["web"].healthcheck.as_ref().unwrap();
642 assert_eq!(hc.retries, Some(3));
643 assert_eq!(hc.interval.as_deref(), Some("30s"));
644 }
645
646 #[test]
647 fn test_compose_serde_roundtrip() {
648 let yaml = r#"
649version: "3"
650services:
651 web:
652 image: nginx:latest
653 ports:
654 - "8080:80"
655"#;
656 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
657 let serialized = serde_yaml::to_string(&config).unwrap();
658 let reparsed = ComposeConfig::from_yaml_str(&serialized).unwrap();
659 assert_eq!(reparsed.services.len(), 1);
660 assert_eq!(
661 reparsed.services["web"].image.as_deref(),
662 Some("nginx:latest")
663 );
664 }
665
666 #[test]
667 fn test_labels_map() {
668 let yaml = r#"
669services:
670 web:
671 image: nginx
672 labels:
673 com.example.env: production
674"#;
675 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
676 assert!(matches!(config.services["web"].labels, Labels::Map(_)));
677 }
678
679 #[test]
680 fn test_service_config_defaults() {
681 let svc = ServiceConfig::default();
682 assert!(svc.image.is_none());
683 assert!(svc.ports.is_empty());
684 assert!(svc.volumes.is_empty());
685 assert!(!svc.privileged);
686 }
687}