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 env_file: StringOrList,
69
70 #[serde(default)]
72 pub ports: Vec<String>,
73
74 #[serde(default)]
76 pub volumes: Vec<String>,
77
78 #[serde(default)]
80 pub depends_on: DependsOn,
81
82 #[serde(default)]
84 pub networks: ServiceNetworks,
85
86 #[serde(default)]
88 pub cpus: Option<u32>,
89
90 #[serde(default)]
92 pub mem_limit: Option<String>,
93
94 #[serde(default)]
96 pub restart: Option<String>,
97
98 #[serde(default)]
100 pub dns: DnsConfig,
101
102 #[serde(default)]
104 pub tmpfs: StringOrList,
105
106 #[serde(default)]
108 pub cap_add: Vec<String>,
109
110 #[serde(default)]
112 pub cap_drop: Vec<String>,
113
114 #[serde(default)]
116 pub privileged: bool,
117
118 #[serde(default)]
120 pub labels: Labels,
121
122 #[serde(default)]
124 pub healthcheck: Option<HealthcheckConfig>,
125
126 #[serde(default)]
128 pub working_dir: Option<String>,
129
130 #[serde(default)]
132 pub hostname: Option<String>,
133
134 #[serde(default)]
136 pub extra_hosts: StringOrList,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct HealthcheckConfig {
142 #[serde(default)]
144 pub test: StringOrList,
145 #[serde(default)]
147 pub disable: bool,
148 #[serde(default)]
150 pub interval: Option<String>,
151 #[serde(default)]
153 pub timeout: Option<String>,
154 #[serde(default)]
156 pub retries: Option<u32>,
157 #[serde(default)]
159 pub start_period: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct VolumeDeclaration {
165 #[serde(default)]
167 pub driver: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172pub struct NetworkDeclaration {
173 #[serde(default)]
175 pub driver: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182#[serde(untagged)]
183pub enum StringOrList {
184 #[default]
185 Empty,
186 Single(String),
187 List(Vec<String>),
188}
189
190impl StringOrList {
191 pub fn to_vec(&self) -> Vec<String> {
193 match self {
194 Self::Empty => vec![],
195 Self::Single(s) => s.split_whitespace().map(String::from).collect(),
196 Self::List(v) => v.clone(),
197 }
198 }
199
200 pub fn is_empty(&self) -> bool {
202 match self {
203 Self::Empty => true,
204 Self::Single(s) => s.is_empty(),
205 Self::List(v) => v.is_empty(),
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
215#[serde(untagged)]
216pub enum EnvVars {
217 #[default]
218 Empty,
219 Map(HashMap<String, String>),
220 List(Vec<String>),
221}
222
223impl EnvVars {
224 pub fn to_pairs(&self) -> Vec<(String, String)> {
226 match self {
227 Self::Empty => vec![],
228 Self::Map(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
229 Self::List(list) => list
230 .iter()
231 .filter_map(|s| {
232 let (k, v) = s.split_once('=')?;
233 Some((k.to_string(), v.to_string()))
234 })
235 .collect(),
236 }
237 }
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, Default)]
245#[serde(untagged)]
246pub enum DependsOn {
247 #[default]
248 Empty,
249 List(Vec<String>),
250 Map(HashMap<String, DependsOnCondition>),
251}
252
253impl DependsOn {
254 pub fn services(&self) -> Vec<String> {
256 match self {
257 Self::Empty => vec![],
258 Self::List(v) => v.clone(),
259 Self::Map(m) => m.keys().cloned().collect(),
260 }
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct DependsOnCondition {
267 #[serde(default = "default_condition")]
269 pub condition: String,
270}
271
272fn default_condition() -> String {
273 "service_started".to_string()
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, Default)]
281#[serde(untagged)]
282pub enum ServiceNetworks {
283 #[default]
284 Empty,
285 List(Vec<String>),
286 Map(HashMap<String, Option<ServiceNetworkConfig>>),
287}
288
289impl ServiceNetworks {
290 pub fn names(&self) -> Vec<String> {
292 match self {
293 Self::Empty => vec![],
294 Self::List(v) => v.clone(),
295 Self::Map(m) => m.keys().cloned().collect(),
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, Default)]
302pub struct ServiceNetworkConfig {
303 #[serde(default)]
305 pub aliases: Vec<String>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, Default)]
310#[serde(untagged)]
311pub enum Labels {
312 #[default]
313 Empty,
314 Map(HashMap<String, String>),
315 List(Vec<String>),
316}
317
318impl Labels {
319 pub fn to_map(&self) -> HashMap<String, String> {
321 match self {
322 Self::Empty => HashMap::new(),
323 Self::Map(map) => map.clone(),
324 Self::List(list) => list
325 .iter()
326 .map(|entry| {
327 let (key, value) = entry.split_once('=').unwrap_or((entry, ""));
328 (key.to_string(), value.to_string())
329 })
330 .collect(),
331 }
332 }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, Default)]
337#[serde(untagged)]
338pub enum DnsConfig {
339 #[default]
340 Empty,
341 Single(String),
342 List(Vec<String>),
343}
344
345impl DnsConfig {
346 pub fn to_vec(&self) -> Vec<String> {
348 match self {
349 Self::Empty => vec![],
350 Self::Single(s) => vec![s.clone()],
351 Self::List(v) => v.clone(),
352 }
353 }
354}
355
356impl ComposeConfig {
357 pub fn from_yaml(yaml: &[u8]) -> Result<Self, serde_yaml::Error> {
359 serde_yaml::from_slice(yaml)
360 }
361
362 pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml::Error> {
364 serde_yaml::from_str(yaml)
365 }
366
367 pub fn service_order(&self) -> Result<Vec<String>, String> {
371 let mut order = Vec::new();
372 let mut state: HashMap<String, u8> = HashMap::new();
374
375 for name in self.services.keys() {
376 if !state.contains_key(name) {
377 self.topo_visit(name, &mut state, &mut order)?;
378 }
379 }
380
381 Ok(order)
382 }
383
384 fn topo_visit(
385 &self,
386 name: &str,
387 state: &mut HashMap<String, u8>,
388 order: &mut Vec<String>,
389 ) -> Result<(), String> {
390 match state.get(name) {
391 Some(1) => {
392 return Err(format!(
393 "Dependency cycle detected involving service '{}'",
394 name
395 ));
396 }
397 Some(2) => return Ok(()), _ => {}
399 }
400
401 state.insert(name.to_string(), 1); if let Some(svc) = self.services.get(name) {
404 let deps = svc.depends_on.services();
405 for dep in &deps {
406 if !self.services.contains_key(dep) {
407 return Err(format!(
408 "Service '{}' depends on '{}' which is not defined",
409 name, dep
410 ));
411 }
412 self.topo_visit(dep, state, order)?;
413 }
414 }
415
416 state.insert(name.to_string(), 2); order.push(name.to_string());
418 Ok(())
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_parse_minimal_compose() {
428 let yaml = r#"
429services:
430 web:
431 image: nginx:latest
432"#;
433 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
434 assert_eq!(config.services.len(), 1);
435 assert_eq!(
436 config.services["web"].image.as_deref(),
437 Some("nginx:latest")
438 );
439 }
440
441 #[test]
442 fn test_parse_full_compose() {
443 let yaml = r#"
444version: "3"
445services:
446 web:
447 image: nginx:latest
448 ports:
449 - "8080:80"
450 depends_on:
451 - db
452 environment:
453 APP_ENV: production
454 volumes:
455 - "static:/usr/share/nginx/html"
456 db:
457 image: postgres:16
458 environment:
459 - POSTGRES_PASSWORD=secret
460 volumes:
461 - "pgdata:/var/lib/postgresql/data"
462 mem_limit: "1g"
463 cpus: 2
464volumes:
465 pgdata:
466 static:
467networks:
468 default:
469"#;
470 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
471 assert_eq!(config.services.len(), 2);
472 assert_eq!(config.volumes.len(), 2);
473 assert!(config.services["web"]
474 .depends_on
475 .services()
476 .contains(&"db".to_string()));
477 assert_eq!(config.services["web"].ports, vec!["8080:80"]);
478 assert_eq!(config.services["db"].cpus, Some(2));
479 assert_eq!(config.services["db"].mem_limit.as_deref(), Some("1g"));
480 }
481
482 #[test]
483 fn test_service_order_simple() {
484 let yaml = r#"
485services:
486 web:
487 image: nginx
488 depends_on: [api]
489 api:
490 image: myapi
491 depends_on: [db]
492 db:
493 image: postgres
494"#;
495 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
496 let order = config.service_order().unwrap();
497 let db_pos = order.iter().position(|s| s == "db").unwrap();
498 let api_pos = order.iter().position(|s| s == "api").unwrap();
499 let web_pos = order.iter().position(|s| s == "web").unwrap();
500 assert!(db_pos < api_pos);
501 assert!(api_pos < web_pos);
502 }
503
504 #[test]
505 fn test_service_order_cycle_detected() {
506 let yaml = r#"
507services:
508 a:
509 image: img
510 depends_on: [b]
511 b:
512 image: img
513 depends_on: [a]
514"#;
515 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
516 let result = config.service_order();
517 assert!(result.is_err());
518 assert!(result.unwrap_err().contains("cycle"));
519 }
520
521 #[test]
522 fn test_service_order_missing_dependency() {
523 let yaml = r#"
524services:
525 web:
526 image: nginx
527 depends_on: [nonexistent]
528"#;
529 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
530 let result = config.service_order();
531 assert!(result.is_err());
532 assert!(result.unwrap_err().contains("not defined"));
533 }
534
535 #[test]
536 fn test_service_order_no_deps() {
537 let yaml = r#"
538services:
539 a:
540 image: img
541 b:
542 image: img
543 c:
544 image: img
545"#;
546 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
547 let order = config.service_order().unwrap();
548 assert_eq!(order.len(), 3);
549 }
550
551 #[test]
552 fn test_env_vars_map() {
553 let yaml = r#"
554services:
555 web:
556 image: nginx
557 environment:
558 KEY1: val1
559 KEY2: val2
560"#;
561 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
562 let pairs = config.services["web"].environment.to_pairs();
563 assert_eq!(pairs.len(), 2);
564 }
565
566 #[test]
567 fn test_env_vars_list() {
568 let yaml = r#"
569services:
570 web:
571 image: nginx
572 environment:
573 - KEY1=val1
574 - KEY2=val2
575"#;
576 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
577 let pairs = config.services["web"].environment.to_pairs();
578 assert_eq!(pairs.len(), 2);
579 assert!(pairs.iter().any(|(k, v)| k == "KEY1" && v == "val1"));
580 }
581
582 #[test]
583 fn test_string_or_list_single() {
584 let sol = StringOrList::Single("echo hello world".to_string());
585 assert_eq!(sol.to_vec(), vec!["echo", "hello", "world"]);
586 assert!(!sol.is_empty());
587 }
588
589 #[test]
590 fn test_string_or_list_list() {
591 let sol = StringOrList::List(vec!["echo".into(), "hello world".into()]);
592 assert_eq!(sol.to_vec(), vec!["echo", "hello world"]);
593 }
594
595 #[test]
596 fn test_string_or_list_empty() {
597 let sol = StringOrList::Empty;
598 assert!(sol.is_empty());
599 assert!(sol.to_vec().is_empty());
600 }
601
602 #[test]
603 fn test_depends_on_list() {
604 let yaml = r#"
605services:
606 web:
607 image: nginx
608 depends_on:
609 - db
610 - redis
611"#;
612 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
613 let deps = config.services["web"].depends_on.services();
614 assert_eq!(deps.len(), 2);
615 assert!(deps.contains(&"db".to_string()));
616 assert!(deps.contains(&"redis".to_string()));
617 }
618
619 #[test]
620 fn test_depends_on_map() {
621 let yaml = r#"
622services:
623 web:
624 image: nginx
625 depends_on:
626 db:
627 condition: service_healthy
628"#;
629 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
630 let deps = config.services["web"].depends_on.services();
631 assert_eq!(deps, vec!["db"]);
632 }
633
634 #[test]
635 fn test_dns_config_single() {
636 let dns = DnsConfig::Single("8.8.8.8".to_string());
637 assert_eq!(dns.to_vec(), vec!["8.8.8.8"]);
638 }
639
640 #[test]
641 fn test_dns_config_list() {
642 let dns = DnsConfig::List(vec!["8.8.8.8".into(), "1.1.1.1".into()]);
643 assert_eq!(dns.to_vec().len(), 2);
644 }
645
646 #[test]
647 fn test_service_networks_list() {
648 let yaml = r#"
649services:
650 web:
651 image: nginx
652 networks:
653 - frontend
654 - backend
655"#;
656 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
657 let nets = config.services["web"].networks.names();
658 assert_eq!(nets.len(), 2);
659 }
660
661 #[test]
662 fn test_healthcheck_config() {
663 let yaml = r#"
664services:
665 web:
666 image: nginx
667 healthcheck:
668 test: ["CMD", "curl", "-f", "http://localhost/"]
669 interval: "30s"
670 timeout: "5s"
671 retries: 3
672"#;
673 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
674 let hc = config.services["web"].healthcheck.as_ref().unwrap();
675 assert_eq!(hc.retries, Some(3));
676 assert_eq!(hc.interval.as_deref(), Some("30s"));
677 assert!(!hc.disable);
678 }
679
680 #[test]
681 fn test_healthcheck_disable() {
682 let yaml = r#"
683services:
684 web:
685 image: nginx
686 healthcheck:
687 disable: true
688"#;
689 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
690 let hc = config.services["web"].healthcheck.as_ref().unwrap();
691 assert!(hc.disable);
692 assert!(hc.test.is_empty());
693 }
694
695 #[test]
696 fn test_compose_serde_roundtrip() {
697 let yaml = r#"
698version: "3"
699services:
700 web:
701 image: nginx:latest
702 ports:
703 - "8080:80"
704"#;
705 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
706 let serialized = serde_yaml::to_string(&config).unwrap();
707 let reparsed = ComposeConfig::from_yaml_str(&serialized).unwrap();
708 assert_eq!(reparsed.services.len(), 1);
709 assert_eq!(
710 reparsed.services["web"].image.as_deref(),
711 Some("nginx:latest")
712 );
713 }
714
715 #[test]
716 fn test_labels_map() {
717 let yaml = r#"
718services:
719 web:
720 image: nginx
721 labels:
722 com.example.env: production
723"#;
724 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
725 assert!(matches!(config.services["web"].labels, Labels::Map(_)));
726 assert_eq!(
727 config.services["web"]
728 .labels
729 .to_map()
730 .get("com.example.env")
731 .map(String::as_str),
732 Some("production")
733 );
734 }
735
736 #[test]
737 fn test_labels_list() {
738 let yaml = r#"
739services:
740 web:
741 image: nginx
742 labels:
743 - "com.example.env=production"
744 - "com.example.debug=true"
745 - "com.example.flag"
746"#;
747 let config = ComposeConfig::from_yaml_str(yaml).unwrap();
748 let labels = config.services["web"].labels.to_map();
749 assert_eq!(
750 labels.get("com.example.env").map(String::as_str),
751 Some("production")
752 );
753 assert_eq!(
754 labels.get("com.example.debug").map(String::as_str),
755 Some("true")
756 );
757 assert_eq!(labels.get("com.example.flag").map(String::as_str), Some(""));
758 }
759
760 #[test]
761 fn test_service_config_defaults() {
762 let svc = ServiceConfig::default();
763 assert!(svc.image.is_none());
764 assert!(svc.ports.is_empty());
765 assert!(svc.volumes.is_empty());
766 assert!(!svc.privileged);
767 }
768}