use std::collections::HashMap;
use yaml_rust2::{Yaml, YamlLoader};
#[derive(Debug, Clone, thiserror::Error)]
pub enum ParseError {
#[error("YAML parse error: {0}")]
YamlError(String),
#[error("Empty document")]
EmptyDocument,
#[error("Invalid structure: {0}")]
InvalidStructure(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Position {
pub line: u32,
pub column: u32,
}
impl Position {
pub fn new(line: u32, column: u32) -> Self {
Self { line, column }
}
}
#[derive(Debug, Clone, Default)]
pub struct ComposeFile {
pub version: Option<String>,
pub version_pos: Option<Position>,
pub name: Option<String>,
pub name_pos: Option<Position>,
pub services: HashMap<String, Service>,
pub services_pos: Option<Position>,
pub networks: HashMap<String, serde_json::Value>,
pub volumes: HashMap<String, serde_json::Value>,
pub configs: HashMap<String, serde_json::Value>,
pub secrets: HashMap<String, serde_json::Value>,
pub top_level_keys: Vec<String>,
pub source: String,
}
#[derive(Debug, Clone, Default)]
pub struct Service {
pub name: String,
pub position: Position,
pub image: Option<String>,
pub image_pos: Option<Position>,
pub build: Option<ServiceBuild>,
pub build_pos: Option<Position>,
pub container_name: Option<String>,
pub container_name_pos: Option<Position>,
pub ports: Vec<ServicePort>,
pub ports_pos: Option<Position>,
pub volumes: Vec<ServiceVolume>,
pub volumes_pos: Option<Position>,
pub depends_on: Vec<String>,
pub depends_on_pos: Option<Position>,
pub environment: HashMap<String, String>,
pub pull_policy: Option<String>,
pub keys: Vec<String>,
pub raw: Option<Yaml>,
}
#[derive(Debug, Clone)]
pub enum ServiceBuild {
Simple(String),
Extended {
context: Option<String>,
dockerfile: Option<String>,
args: HashMap<String, String>,
target: Option<String>,
},
}
impl Default for ServiceBuild {
fn default() -> Self {
Self::Simple(".".to_string())
}
}
#[derive(Debug, Clone)]
pub struct ServicePort {
pub raw: String,
pub position: Position,
pub is_quoted: bool,
pub host_port: Option<u16>,
pub container_port: u16,
pub host_ip: Option<String>,
pub protocol: Option<String>,
}
impl ServicePort {
pub fn parse(raw: &str, position: Position, is_quoted: bool) -> Option<Self> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let (port_part, protocol) = if raw.contains('/') {
let parts: Vec<&str> = raw.rsplitn(2, '/').collect();
(parts[1], Some(parts[0].to_string()))
} else {
(raw, None)
};
let parts: Vec<&str> = port_part.split(':').collect();
let (host_ip, host_port, container_port) = match parts.len() {
1 => {
let cp = parts[0].parse().ok()?;
(None, None, cp)
}
2 => {
let hp = parts[0].parse().ok();
let cp = parts[1].parse().ok()?;
(None, hp, cp)
}
3 => {
let ip = Some(parts[0].to_string());
let hp = parts[1].parse().ok();
let cp = parts[2].parse().ok()?;
(ip, hp, cp)
}
_ => return None,
};
Some(Self {
raw: raw.to_string(),
position,
is_quoted,
host_port,
container_port,
host_ip,
protocol,
})
}
pub fn has_explicit_interface(&self) -> bool {
self.host_ip.is_some()
}
pub fn exported_port(&self) -> Option<String> {
self.host_port.map(|p| {
if let Some(ip) = &self.host_ip {
format!("{}:{}", ip, p)
} else {
p.to_string()
}
})
}
}
#[derive(Debug, Clone)]
pub struct ServiceVolume {
pub raw: String,
pub position: Position,
pub is_quoted: bool,
pub source: Option<String>,
pub target: String,
pub options: Option<String>,
}
impl ServiceVolume {
pub fn parse(raw: &str, position: Position, is_quoted: bool) -> Option<Self> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let parts: Vec<&str> = raw.splitn(3, ':').collect();
let (source, target, options) = match parts.len() {
1 => (None, parts[0].to_string(), None),
2 => (Some(parts[0].to_string()), parts[1].to_string(), None),
3 => (
Some(parts[0].to_string()),
parts[1].to_string(),
Some(parts[2].to_string()),
),
_ => return None,
};
Some(Self {
raw: raw.to_string(),
position,
is_quoted,
source,
target,
options,
})
}
}
pub fn parse_compose(content: &str) -> Result<ComposeFile, ParseError> {
parse_compose_with_positions(content)
}
pub fn parse_compose_with_positions(content: &str) -> Result<ComposeFile, ParseError> {
let docs =
YamlLoader::load_from_str(content).map_err(|e| ParseError::YamlError(e.to_string()))?;
let doc = docs.into_iter().next().ok_or(ParseError::EmptyDocument)?;
let hash = match &doc {
Yaml::Hash(h) => h,
_ => {
return Err(ParseError::InvalidStructure(
"Root must be a mapping".to_string(),
));
}
};
let mut compose = ComposeFile {
source: content.to_string(),
..Default::default()
};
for (key, _) in hash {
if let Yaml::String(k) = key {
compose.top_level_keys.push(k.clone());
}
}
if let Some(Yaml::String(version)) = hash.get(&Yaml::String("version".to_string())) {
compose.version = Some(version.clone());
compose.version_pos =
super::find_line_for_key(content, &["version"]).map(|l| Position::new(l, 1));
}
if let Some(Yaml::String(name)) = hash.get(&Yaml::String("name".to_string())) {
compose.name = Some(name.clone());
compose.name_pos =
super::find_line_for_key(content, &["name"]).map(|l| Position::new(l, 1));
}
if let Some(Yaml::Hash(services)) = hash.get(&Yaml::String("services".to_string())) {
compose.services_pos =
super::find_line_for_key(content, &["services"]).map(|l| Position::new(l, 1));
for (name_yaml, service_yaml) in services {
if let Yaml::String(name) = name_yaml {
let service = parse_service(name, service_yaml, content)?;
compose.services.insert(name.clone(), service);
}
}
}
if let Some(Yaml::Hash(networks)) = hash.get(&Yaml::String("networks".to_string())) {
for (name_yaml, value_yaml) in networks {
if let Yaml::String(name) = name_yaml {
compose
.networks
.insert(name.clone(), yaml_to_json(value_yaml));
}
}
}
if let Some(Yaml::Hash(volumes)) = hash.get(&Yaml::String("volumes".to_string())) {
for (name_yaml, value_yaml) in volumes {
if let Yaml::String(name) = name_yaml {
compose
.volumes
.insert(name.clone(), yaml_to_json(value_yaml));
}
}
}
Ok(compose)
}
fn parse_service(name: &str, yaml: &Yaml, source: &str) -> Result<Service, ParseError> {
let hash = match yaml {
Yaml::Hash(h) => h,
Yaml::Null => {
return Ok(Service {
name: name.to_string(),
..Default::default()
});
}
_ => {
return Err(ParseError::InvalidStructure(format!(
"Service '{}' must be a mapping",
name
)));
}
};
let position = super::find_line_for_service(source, name)
.map(|l| Position::new(l, 1))
.unwrap_or_default();
let mut service = Service {
name: name.to_string(),
position,
raw: Some(yaml.clone()),
..Default::default()
};
for (key, _) in hash {
if let Yaml::String(k) = key {
service.keys.push(k.clone());
}
}
if let Some(Yaml::String(image)) = hash.get(&Yaml::String("image".to_string())) {
service.image = Some(image.clone());
service.image_pos =
super::find_line_for_service_key(source, name, "image").map(|l| Position::new(l, 1));
}
if let Some(build_yaml) = hash.get(&Yaml::String("build".to_string())) {
service.build_pos =
super::find_line_for_service_key(source, name, "build").map(|l| Position::new(l, 1));
service.build = Some(match build_yaml {
Yaml::String(s) => ServiceBuild::Simple(s.clone()),
Yaml::Hash(h) => {
let context = h
.get(&Yaml::String("context".to_string()))
.and_then(|v| match v {
Yaml::String(s) => Some(s.clone()),
_ => None,
});
let dockerfile =
h.get(&Yaml::String("dockerfile".to_string()))
.and_then(|v| match v {
Yaml::String(s) => Some(s.clone()),
_ => None,
});
let target = h
.get(&Yaml::String("target".to_string()))
.and_then(|v| match v {
Yaml::String(s) => Some(s.clone()),
_ => None,
});
ServiceBuild::Extended {
context,
dockerfile,
args: HashMap::new(),
target,
}
}
_ => ServiceBuild::Simple(".".to_string()),
});
}
if let Some(Yaml::String(container_name)) =
hash.get(&Yaml::String("container_name".to_string()))
{
service.container_name = Some(container_name.clone());
service.container_name_pos =
super::find_line_for_service_key(source, name, "container_name")
.map(|l| Position::new(l, 1));
}
if let Some(Yaml::Array(ports)) = hash.get(&Yaml::String("ports".to_string())) {
service.ports_pos =
super::find_line_for_service_key(source, name, "ports").map(|l| Position::new(l, 1));
let ports_start_line = service.ports_pos.map(|p| p.line).unwrap_or(1);
for (idx, port_yaml) in ports.iter().enumerate() {
let line = ports_start_line + 1 + idx as u32;
let position = Position::new(line, 1);
match port_yaml {
Yaml::String(s) => {
let is_quoted = is_value_quoted_at_line(source, line);
if let Some(port) = ServicePort::parse(s, position, is_quoted) {
service.ports.push(port);
}
}
Yaml::Integer(i) => {
let raw = i.to_string();
if let Some(port) = ServicePort::parse(&raw, position, false) {
service.ports.push(port);
}
}
Yaml::Hash(h) => {
let target = h
.get(&Yaml::String("target".to_string()))
.and_then(|v| match v {
Yaml::Integer(i) => Some(*i as u16),
Yaml::String(s) => s.parse().ok(),
_ => None,
});
let published =
h.get(&Yaml::String("published".to_string()))
.and_then(|v| match v {
Yaml::Integer(i) => Some(*i as u16),
Yaml::String(s) => s.parse().ok(),
_ => None,
});
let host_ip =
h.get(&Yaml::String("host_ip".to_string()))
.and_then(|v| match v {
Yaml::String(s) => Some(s.clone()),
_ => None,
});
if let Some(container_port) = target {
service.ports.push(ServicePort {
raw: format!(
"{}:{}",
published.unwrap_or(container_port),
container_port
),
position,
is_quoted: false,
host_port: published,
container_port,
host_ip,
protocol: None,
});
}
}
_ => {}
}
}
}
if let Some(Yaml::Array(volumes)) = hash.get(&Yaml::String("volumes".to_string())) {
service.volumes_pos =
super::find_line_for_service_key(source, name, "volumes").map(|l| Position::new(l, 1));
let volumes_start_line = service.volumes_pos.map(|p| p.line).unwrap_or(1);
for (idx, vol_yaml) in volumes.iter().enumerate() {
let line = volumes_start_line + 1 + idx as u32;
let position = Position::new(line, 1);
if let Yaml::String(s) = vol_yaml {
let is_quoted = is_value_quoted_at_line(source, line);
if let Some(vol) = ServiceVolume::parse(s, position, is_quoted) {
service.volumes.push(vol);
}
}
}
}
if let Some(depends_on_yaml) = hash.get(&Yaml::String("depends_on".to_string())) {
service.depends_on_pos = super::find_line_for_service_key(source, name, "depends_on")
.map(|l| Position::new(l, 1));
match depends_on_yaml {
Yaml::Array(arr) => {
for dep in arr {
if let Yaml::String(s) = dep {
service.depends_on.push(s.clone());
}
}
}
Yaml::Hash(h) => {
for (dep_name, _) in h {
if let Yaml::String(s) = dep_name {
service.depends_on.push(s.clone());
}
}
}
_ => {}
}
}
if let Some(env_yaml) = hash.get(&Yaml::String("environment".to_string())) {
match env_yaml {
Yaml::Hash(h) => {
for (key, value) in h {
if let (Yaml::String(k), v) = (key, value) {
let val = match v {
Yaml::String(s) => s.clone(),
Yaml::Integer(i) => i.to_string(),
Yaml::Boolean(b) => b.to_string(),
Yaml::Null => String::new(),
_ => continue,
};
service.environment.insert(k.clone(), val);
}
}
}
Yaml::Array(arr) => {
for item in arr {
if let Yaml::String(s) = item {
if let Some((k, v)) = s.split_once('=') {
service.environment.insert(k.to_string(), v.to_string());
} else {
service.environment.insert(s.clone(), String::new());
}
}
}
}
_ => {}
}
}
if let Some(Yaml::String(pull_policy)) = hash.get(&Yaml::String("pull_policy".to_string())) {
service.pull_policy = Some(pull_policy.clone());
}
Ok(service)
}
fn is_value_quoted_at_line(source: &str, line: u32) -> bool {
let lines: Vec<&str> = source.lines().collect();
if let Some(line_content) = lines.get((line - 1) as usize) {
let trimmed = line_content.trim();
if trimmed.starts_with('-') {
let after_dash = trimmed.trim_start_matches('-').trim();
return after_dash.starts_with('"') || after_dash.starts_with('\'');
}
if let Some(pos) = trimmed.find(':') {
let after_colon = trimmed[pos + 1..].trim();
return after_colon.starts_with('"') || after_colon.starts_with('\'');
}
}
false
}
fn yaml_to_json(yaml: &Yaml) -> serde_json::Value {
match yaml {
Yaml::Null => serde_json::Value::Null,
Yaml::Boolean(b) => serde_json::Value::Bool(*b),
Yaml::Integer(i) => serde_json::json!(i),
Yaml::Real(r) => {
if let Ok(f) = r.parse::<f64>() {
serde_json::json!(f)
} else {
serde_json::Value::String(r.clone())
}
}
Yaml::String(s) => serde_json::Value::String(s.clone()),
Yaml::Array(arr) => serde_json::Value::Array(arr.iter().map(yaml_to_json).collect()),
Yaml::Hash(h) => {
let mut map = serde_json::Map::new();
for (k, v) in h {
if let Yaml::String(key) = k {
map.insert(key.clone(), yaml_to_json(v));
}
}
serde_json::Value::Object(map)
}
_ => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_compose() {
let yaml = r#"
version: "3.8"
name: myproject
services:
web:
image: nginx:latest
ports:
- "8080:80"
db:
image: postgres:15
"#;
let compose = parse_compose(yaml).unwrap();
assert_eq!(compose.version, Some("3.8".to_string()));
assert_eq!(compose.name, Some("myproject".to_string()));
assert_eq!(compose.services.len(), 2);
let web = compose.services.get("web").unwrap();
assert_eq!(web.image, Some("nginx:latest".to_string()));
assert_eq!(web.ports.len(), 1);
assert_eq!(web.ports[0].container_port, 80);
assert_eq!(web.ports[0].host_port, Some(8080));
}
#[test]
fn test_parse_build_and_image() {
let yaml = r#"
services:
app:
build: .
image: myapp:latest
"#;
let compose = parse_compose(yaml).unwrap();
let app = compose.services.get("app").unwrap();
assert!(app.build.is_some());
assert!(app.image.is_some());
}
#[test]
fn test_parse_port_formats() {
let yaml = r#"
services:
web:
image: nginx
ports:
- 80
- "8080:80"
- "127.0.0.1:8081:80"
"#;
let compose = parse_compose(yaml).unwrap();
let web = compose.services.get("web").unwrap();
assert_eq!(web.ports.len(), 3);
assert_eq!(web.ports[0].container_port, 80);
assert_eq!(web.ports[0].host_port, None);
assert_eq!(web.ports[1].container_port, 80);
assert_eq!(web.ports[1].host_port, Some(8080));
assert_eq!(web.ports[2].container_port, 80);
assert_eq!(web.ports[2].host_port, Some(8081));
assert_eq!(web.ports[2].host_ip, Some("127.0.0.1".to_string()));
}
#[test]
fn test_parse_depends_on() {
let yaml = r#"
services:
web:
image: nginx
depends_on:
- db
- redis
db:
image: postgres
redis:
image: redis
"#;
let compose = parse_compose(yaml).unwrap();
let web = compose.services.get("web").unwrap();
assert_eq!(web.depends_on, vec!["db", "redis"]);
}
#[test]
fn test_port_parsing() {
let pos = Position::new(1, 1);
let p1 = ServicePort::parse("80", pos, false).unwrap();
assert_eq!(p1.container_port, 80);
assert_eq!(p1.host_port, None);
let p2 = ServicePort::parse("8080:80", pos, true).unwrap();
assert_eq!(p2.container_port, 80);
assert_eq!(p2.host_port, Some(8080));
assert!(p2.is_quoted);
let p3 = ServicePort::parse("127.0.0.1:8080:80", pos, false).unwrap();
assert_eq!(p3.container_port, 80);
assert_eq!(p3.host_port, Some(8080));
assert_eq!(p3.host_ip, Some("127.0.0.1".to_string()));
let p4 = ServicePort::parse("80/udp", pos, false).unwrap();
assert_eq!(p4.container_port, 80);
assert_eq!(p4.protocol, Some("udp".to_string()));
}
#[test]
fn test_volume_parsing() {
let pos = Position::new(1, 1);
let v1 = ServiceVolume::parse("/data", pos, false).unwrap();
assert_eq!(v1.target, "/data");
assert_eq!(v1.source, None);
let v2 = ServiceVolume::parse("./host:/container", pos, false).unwrap();
assert_eq!(v2.source, Some("./host".to_string()));
assert_eq!(v2.target, "/container");
let v3 = ServiceVolume::parse("./host:/container:ro", pos, false).unwrap();
assert_eq!(v3.source, Some("./host".to_string()));
assert_eq!(v3.target, "/container");
assert_eq!(v3.options, Some("ro".to_string()));
}
}