use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeConfig {
#[serde(default)]
pub version: Option<String>,
pub services: HashMap<String, ServiceConfig>,
#[serde(default)]
pub volumes: HashMap<String, Option<VolumeDeclaration>>,
#[serde(default)]
pub networks: HashMap<String, Option<NetworkDeclaration>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceConfig {
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub entrypoint: Option<StringOrList>,
#[serde(default)]
pub command: Option<StringOrList>,
#[serde(default)]
pub environment: EnvVars,
#[serde(default)]
pub env_file: StringOrList,
#[serde(default)]
pub ports: Vec<String>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub depends_on: DependsOn,
#[serde(default)]
pub networks: ServiceNetworks,
#[serde(default)]
pub cpus: Option<u32>,
#[serde(default)]
pub mem_limit: Option<String>,
#[serde(default)]
pub restart: Option<String>,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default)]
pub tmpfs: StringOrList,
#[serde(default)]
pub cap_add: Vec<String>,
#[serde(default)]
pub cap_drop: Vec<String>,
#[serde(default)]
pub privileged: bool,
#[serde(default)]
pub labels: Labels,
#[serde(default)]
pub healthcheck: Option<HealthcheckConfig>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub extra_hosts: StringOrList,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthcheckConfig {
#[serde(default)]
pub test: StringOrList,
#[serde(default)]
pub disable: bool,
#[serde(default)]
pub interval: Option<String>,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default)]
pub retries: Option<u32>,
#[serde(default)]
pub start_period: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VolumeDeclaration {
#[serde(default)]
pub driver: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NetworkDeclaration {
#[serde(default)]
pub driver: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum StringOrList {
#[default]
Empty,
Single(String),
List(Vec<String>),
}
impl StringOrList {
pub fn to_vec(&self) -> Vec<String> {
match self {
Self::Empty => vec![],
Self::Single(s) => s.split_whitespace().map(String::from).collect(),
Self::List(v) => v.clone(),
}
}
pub fn is_empty(&self) -> bool {
match self {
Self::Empty => true,
Self::Single(s) => s.is_empty(),
Self::List(v) => v.is_empty(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum EnvVars {
#[default]
Empty,
Map(HashMap<String, String>),
List(Vec<String>),
}
impl EnvVars {
pub fn to_pairs(&self) -> Vec<(String, String)> {
match self {
Self::Empty => vec![],
Self::Map(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
Self::List(list) => list
.iter()
.filter_map(|s| {
let (k, v) = s.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum DependsOn {
#[default]
Empty,
List(Vec<String>),
Map(HashMap<String, DependsOnCondition>),
}
impl DependsOn {
pub fn services(&self) -> Vec<String> {
match self {
Self::Empty => vec![],
Self::List(v) => v.clone(),
Self::Map(m) => m.keys().cloned().collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependsOnCondition {
#[serde(default = "default_condition")]
pub condition: String,
}
fn default_condition() -> String {
"service_started".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum ServiceNetworks {
#[default]
Empty,
List(Vec<String>),
Map(HashMap<String, Option<ServiceNetworkConfig>>),
}
impl ServiceNetworks {
pub fn names(&self) -> Vec<String> {
match self {
Self::Empty => vec![],
Self::List(v) => v.clone(),
Self::Map(m) => m.keys().cloned().collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceNetworkConfig {
#[serde(default)]
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum Labels {
#[default]
Empty,
Map(HashMap<String, String>),
List(Vec<String>),
}
impl Labels {
pub fn to_map(&self) -> HashMap<String, String> {
match self {
Self::Empty => HashMap::new(),
Self::Map(map) => map.clone(),
Self::List(list) => list
.iter()
.map(|entry| {
let (key, value) = entry.split_once('=').unwrap_or((entry, ""));
(key.to_string(), value.to_string())
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum DnsConfig {
#[default]
Empty,
Single(String),
List(Vec<String>),
}
impl DnsConfig {
pub fn to_vec(&self) -> Vec<String> {
match self {
Self::Empty => vec![],
Self::Single(s) => vec![s.clone()],
Self::List(v) => v.clone(),
}
}
}
impl ComposeConfig {
pub fn from_yaml(yaml: &[u8]) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_slice(yaml)
}
pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
pub fn service_order(&self) -> Result<Vec<String>, String> {
let mut order = Vec::new();
let mut state: HashMap<String, u8> = HashMap::new();
for name in self.services.keys() {
if !state.contains_key(name) {
self.topo_visit(name, &mut state, &mut order)?;
}
}
Ok(order)
}
fn topo_visit(
&self,
name: &str,
state: &mut HashMap<String, u8>,
order: &mut Vec<String>,
) -> Result<(), String> {
match state.get(name) {
Some(1) => {
return Err(format!(
"Dependency cycle detected involving service '{}'",
name
));
}
Some(2) => return Ok(()), _ => {}
}
state.insert(name.to_string(), 1);
if let Some(svc) = self.services.get(name) {
let deps = svc.depends_on.services();
for dep in &deps {
if !self.services.contains_key(dep) {
return Err(format!(
"Service '{}' depends on '{}' which is not defined",
name, dep
));
}
self.topo_visit(dep, state, order)?;
}
}
state.insert(name.to_string(), 2); order.push(name.to_string());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_compose() {
let yaml = r#"
services:
web:
image: nginx:latest
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
assert_eq!(config.services.len(), 1);
assert_eq!(
config.services["web"].image.as_deref(),
Some("nginx:latest")
);
}
#[test]
fn test_parse_full_compose() {
let yaml = r#"
version: "3"
services:
web:
image: nginx:latest
ports:
- "8080:80"
depends_on:
- db
environment:
APP_ENV: production
volumes:
- "static:/usr/share/nginx/html"
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=secret
volumes:
- "pgdata:/var/lib/postgresql/data"
mem_limit: "1g"
cpus: 2
volumes:
pgdata:
static:
networks:
default:
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
assert_eq!(config.services.len(), 2);
assert_eq!(config.volumes.len(), 2);
assert!(config.services["web"]
.depends_on
.services()
.contains(&"db".to_string()));
assert_eq!(config.services["web"].ports, vec!["8080:80"]);
assert_eq!(config.services["db"].cpus, Some(2));
assert_eq!(config.services["db"].mem_limit.as_deref(), Some("1g"));
}
#[test]
fn test_service_order_simple() {
let yaml = r#"
services:
web:
image: nginx
depends_on: [api]
api:
image: myapi
depends_on: [db]
db:
image: postgres
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let order = config.service_order().unwrap();
let db_pos = order.iter().position(|s| s == "db").unwrap();
let api_pos = order.iter().position(|s| s == "api").unwrap();
let web_pos = order.iter().position(|s| s == "web").unwrap();
assert!(db_pos < api_pos);
assert!(api_pos < web_pos);
}
#[test]
fn test_service_order_cycle_detected() {
let yaml = r#"
services:
a:
image: img
depends_on: [b]
b:
image: img
depends_on: [a]
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let result = config.service_order();
assert!(result.is_err());
assert!(result.unwrap_err().contains("cycle"));
}
#[test]
fn test_service_order_missing_dependency() {
let yaml = r#"
services:
web:
image: nginx
depends_on: [nonexistent]
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let result = config.service_order();
assert!(result.is_err());
assert!(result.unwrap_err().contains("not defined"));
}
#[test]
fn test_service_order_no_deps() {
let yaml = r#"
services:
a:
image: img
b:
image: img
c:
image: img
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let order = config.service_order().unwrap();
assert_eq!(order.len(), 3);
}
#[test]
fn test_env_vars_map() {
let yaml = r#"
services:
web:
image: nginx
environment:
KEY1: val1
KEY2: val2
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let pairs = config.services["web"].environment.to_pairs();
assert_eq!(pairs.len(), 2);
}
#[test]
fn test_env_vars_list() {
let yaml = r#"
services:
web:
image: nginx
environment:
- KEY1=val1
- KEY2=val2
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let pairs = config.services["web"].environment.to_pairs();
assert_eq!(pairs.len(), 2);
assert!(pairs.iter().any(|(k, v)| k == "KEY1" && v == "val1"));
}
#[test]
fn test_string_or_list_single() {
let sol = StringOrList::Single("echo hello world".to_string());
assert_eq!(sol.to_vec(), vec!["echo", "hello", "world"]);
assert!(!sol.is_empty());
}
#[test]
fn test_string_or_list_list() {
let sol = StringOrList::List(vec!["echo".into(), "hello world".into()]);
assert_eq!(sol.to_vec(), vec!["echo", "hello world"]);
}
#[test]
fn test_string_or_list_empty() {
let sol = StringOrList::Empty;
assert!(sol.is_empty());
assert!(sol.to_vec().is_empty());
}
#[test]
fn test_depends_on_list() {
let yaml = r#"
services:
web:
image: nginx
depends_on:
- db
- redis
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let deps = config.services["web"].depends_on.services();
assert_eq!(deps.len(), 2);
assert!(deps.contains(&"db".to_string()));
assert!(deps.contains(&"redis".to_string()));
}
#[test]
fn test_depends_on_map() {
let yaml = r#"
services:
web:
image: nginx
depends_on:
db:
condition: service_healthy
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let deps = config.services["web"].depends_on.services();
assert_eq!(deps, vec!["db"]);
}
#[test]
fn test_dns_config_single() {
let dns = DnsConfig::Single("8.8.8.8".to_string());
assert_eq!(dns.to_vec(), vec!["8.8.8.8"]);
}
#[test]
fn test_dns_config_list() {
let dns = DnsConfig::List(vec!["8.8.8.8".into(), "1.1.1.1".into()]);
assert_eq!(dns.to_vec().len(), 2);
}
#[test]
fn test_service_networks_list() {
let yaml = r#"
services:
web:
image: nginx
networks:
- frontend
- backend
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let nets = config.services["web"].networks.names();
assert_eq!(nets.len(), 2);
}
#[test]
fn test_healthcheck_config() {
let yaml = r#"
services:
web:
image: nginx
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: "30s"
timeout: "5s"
retries: 3
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let hc = config.services["web"].healthcheck.as_ref().unwrap();
assert_eq!(hc.retries, Some(3));
assert_eq!(hc.interval.as_deref(), Some("30s"));
assert!(!hc.disable);
}
#[test]
fn test_healthcheck_disable() {
let yaml = r#"
services:
web:
image: nginx
healthcheck:
disable: true
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let hc = config.services["web"].healthcheck.as_ref().unwrap();
assert!(hc.disable);
assert!(hc.test.is_empty());
}
#[test]
fn test_compose_serde_roundtrip() {
let yaml = r#"
version: "3"
services:
web:
image: nginx:latest
ports:
- "8080:80"
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let serialized = serde_yaml::to_string(&config).unwrap();
let reparsed = ComposeConfig::from_yaml_str(&serialized).unwrap();
assert_eq!(reparsed.services.len(), 1);
assert_eq!(
reparsed.services["web"].image.as_deref(),
Some("nginx:latest")
);
}
#[test]
fn test_labels_map() {
let yaml = r#"
services:
web:
image: nginx
labels:
com.example.env: production
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
assert!(matches!(config.services["web"].labels, Labels::Map(_)));
assert_eq!(
config.services["web"]
.labels
.to_map()
.get("com.example.env")
.map(String::as_str),
Some("production")
);
}
#[test]
fn test_labels_list() {
let yaml = r#"
services:
web:
image: nginx
labels:
- "com.example.env=production"
- "com.example.debug=true"
- "com.example.flag"
"#;
let config = ComposeConfig::from_yaml_str(yaml).unwrap();
let labels = config.services["web"].labels.to_map();
assert_eq!(
labels.get("com.example.env").map(String::as_str),
Some("production")
);
assert_eq!(
labels.get("com.example.debug").map(String::as_str),
Some("true")
);
assert_eq!(labels.get("com.example.flag").map(String::as_str), Some(""));
}
#[test]
fn test_service_config_defaults() {
let svc = ServiceConfig::default();
assert!(svc.image.is_none());
assert!(svc.ports.is_empty());
assert!(svc.volumes.is_empty());
assert!(!svc.privileged);
}
}