pub mod types;
mod anchor;
mod diagnostics;
mod extends;
mod include;
mod merge;
mod order;
use std::path::{Path, PathBuf};
use crate::error::{ComposeError, Result};
use crate::substitute;
use types::{ComposeFile, ServiceNetworks};
pub use order::{resolve_levels, resolve_order};
pub fn parse_file(path: &Path) -> Result<ComposeFile> {
parse_file_with_env_files(path, &[])
}
pub fn parse_file_with_env_files(path: &Path, env_files: &[String]) -> Result<ComposeFile> {
parse_file_with_env_files_interp(path, env_files, true)
}
pub fn parse_file_with_env_files_interp(
path: &Path,
env_files: &[String],
interpolate: bool,
) -> Result<ComposeFile> {
let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let dir = abs.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut file = parse_file_inner_with_env(&abs, &dir, env_files, interpolate)?;
let includes = std::mem::take(&mut file.include);
for inc in includes {
let (extra_env_files, project_dir_override) = match &inc {
types::IncludeConfig::Long {
env_file,
project_directory,
..
} => (
env_file.as_ref().map(|ef| ef.to_list()).unwrap_or_default(),
project_directory.as_ref().map(|pd| dir.join(pd)),
),
_ => (vec![], None),
};
for rel in inc.paths() {
let rel_path = std::path::Path::new(&rel);
let inc_path = if rel_path.is_absolute() {
rel_path.to_path_buf()
} else {
dir.join(&rel)
};
let inc_dir = project_dir_override.clone().unwrap_or_else(|| {
inc_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| dir.clone())
});
let mut combined_env_files = env_files.to_vec();
combined_env_files.extend(extra_env_files.iter().cloned());
let mut included =
parse_file_inner_with_env(&inc_path, &inc_dir, &combined_env_files, interpolate)?;
anchor::anchor_compose_file(&mut included, &inc_dir);
include::merge_compose_file(&mut file, included);
}
}
extends::resolve_all_extends(&mut file, &dir)?;
Ok(file)
}
pub fn collect_diagnostics(file: &ComposeFile) -> Vec<String> {
diagnostics::collect(file)
}
pub fn parse_files_with_env_files(paths: &[PathBuf], env_files: &[String]) -> Result<ComposeFile> {
parse_files_with_env_files_interp(paths, env_files, true)
}
pub fn parse_files_with_env_files_interp(
paths: &[PathBuf],
env_files: &[String],
interpolate: bool,
) -> Result<ComposeFile> {
let mut iter = paths.iter();
let first = iter
.next()
.ok_or_else(|| ComposeError::FileNotFound("no compose file given".to_string()))?;
let mut merged = parse_file_with_env_files_interp(first, env_files, interpolate)?;
for path in iter {
let other = parse_file_with_env_files_interp(path, env_files, interpolate)?;
merge_override(&mut merged, other);
}
normalize_default_network(&mut merged);
for warning in diagnostics::collect(&merged) {
tracing::warn!("{warning}");
}
Ok(merged)
}
pub(crate) fn normalize_default_network(file: &mut ComposeFile) {
let needs_default = file
.services
.values()
.any(|svc| svc.network_mode.is_none() && matches!(svc.networks, ServiceNetworks::Empty));
if !needs_default {
return;
}
file.networks.entry("default".to_string()).or_insert(None);
for svc in file.services.values_mut() {
if svc.network_mode.is_none() && matches!(svc.networks, ServiceNetworks::Empty) {
svc.networks = ServiceNetworks::List(vec!["default".to_string()]);
}
}
}
fn merge_override(target: &mut ComposeFile, other: ComposeFile) {
for (name, svc) in other.services {
if let Some(base) = target.services.get_mut(&name) {
*base = extends::merge_service(std::mem::take(base), svc);
} else {
target.services.insert(name, svc);
}
}
for (k, v) in other.volumes {
target.volumes.insert(k, v);
}
for (k, v) in other.networks {
target.networks.insert(k, v);
}
for (k, v) in other.secrets {
target.secrets.insert(k, v);
}
for (k, v) in other.configs {
target.configs.insert(k, v);
}
}
pub fn parse_str(content: &str) -> Result<ComposeFile> {
let vars = substitute::build_vars(Path::new("."));
let substituted = substitute::substitute(content, &vars)?;
let mut file = merge::deserialize_with_merge(&substituted)?;
extends::resolve_extends_same_file(&mut file)?;
Ok(file)
}
pub fn parse_str_raw(content: &str) -> Result<ComposeFile> {
merge::deserialize_with_merge(content)
}
pub(crate) fn parse_file_inner(path: &Path, dir: &Path) -> Result<ComposeFile> {
parse_file_inner_with_env(path, dir, &[], true)
}
pub(crate) fn parse_file_inner_with_env(
path: &Path,
dir: &Path,
extra_env_files: &[String],
interpolate: bool,
) -> Result<ComposeFile> {
let content = crate::filesystem::read_to_string_capped(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ComposeError::FileNotFound(path.display().to_string())
} else {
ComposeError::Io(e)
}
})?;
let yaml = if interpolate {
let vars = if extra_env_files.is_empty() {
substitute::build_vars(dir)
} else {
substitute::build_vars_with_env_files(dir, extra_env_files)
};
substitute::substitute(&content, &vars)?
} else {
content
};
merge::deserialize_with_merge(&yaml)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_str_raw_minimal_service() {
let yaml = "services:\n web:\n image: nginx\n";
let file = parse_str_raw(yaml).unwrap();
assert!(file.services.contains_key("web"));
assert_eq!(file.services["web"].image.as_deref(), Some("nginx"));
}
#[test]
fn collect_diagnostics_surfaces_unknown_keys() {
let file =
parse_str_raw("services:\n web:\n image: nginx\n enviroment:\n - A=1\n")
.unwrap();
let diags = collect_diagnostics(&file);
assert!(
diags.iter().any(|d| d.contains("enviroment")),
"expected an unknown-key diagnostic, got {diags:?}"
);
}
#[test]
fn parse_str_raw_invalid_yaml_is_error() {
assert!(parse_str_raw(": : :").is_err());
}
#[test]
fn unknown_service_key_is_captured_not_dropped() {
let yaml = "services:\n web:\n image: nginx\n enviroment:\n - A=1\n";
let file = parse_str_raw(yaml).unwrap();
assert!(file.services["web"].unknown.contains_key("enviroment"));
assert!(file.services["web"].environment.is_empty());
}
#[test]
fn known_service_keys_do_not_land_in_unknown() {
let yaml = "services:\n web:\n image: nginx\n environment:\n - A=1\n";
let file = parse_str_raw(yaml).unwrap();
assert!(file.services["web"].unknown.is_empty());
}
#[test]
fn yaml_merge_key_fills_missing_fields() {
let yaml = "x-defaults: &defaults\n image: nginx\n restart: always\nservices:\n web:\n <<: *defaults\n ports: ['80:80']\n";
let file = parse_str_raw(yaml).unwrap();
assert_eq!(file.services["web"].image.as_deref(), Some("nginx"));
}
#[test]
fn normalize_attaches_bare_service_to_default_network() {
let mut file = parse_str("services:\n web:\n image: nginx\n").unwrap();
normalize_default_network(&mut file);
assert!(file.networks.contains_key("default"));
assert_eq!(file.services["web"].networks.names(), vec!["default"]);
}
#[test]
fn normalize_leaves_service_with_explicit_networks_untouched() {
let mut file = parse_str(
"services:\n web:\n image: nginx\n networks: [front]\nnetworks:\n front:\n",
)
.unwrap();
normalize_default_network(&mut file);
assert_eq!(file.services["web"].networks.names(), vec!["front"]);
assert!(!file.networks.contains_key("default"));
}
#[test]
fn normalize_skips_service_with_network_mode() {
let mut file =
parse_str("services:\n web:\n image: nginx\n network_mode: host\n").unwrap();
normalize_default_network(&mut file);
assert!(file.services["web"].networks.names().is_empty());
assert!(!file.networks.contains_key("default"));
}
#[test]
fn normalize_respects_explicit_default_network_config() {
let mut file = parse_str(
"services:\n web:\n image: nginx\nnetworks:\n default:\n driver: bridge\n",
)
.unwrap();
normalize_default_network(&mut file);
assert!(file.networks["default"].is_some());
assert_eq!(file.services["web"].networks.names(), vec!["default"]);
}
}