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