use std::collections::HashMap;
use std::path::Path;
use crate::compose::types::{ComposeFile, Service};
use crate::env_file;
use crate::error::Result;
pub(super) fn resolve_volume_name(reference: &str, project: &str, file: &ComposeFile) -> String {
match file.volumes.get(reference) {
Some(cfg) => {
if let Some(name) = cfg.as_ref().and_then(|c| c.name.as_deref()) {
name.to_string()
} else if cfg.as_ref().and_then(|c| c.external).unwrap_or(false) {
reference.to_string()
} else {
format!("{project}_{reference}")
}
}
None => reference.to_string(),
}
}
pub(crate) fn resolve_bind_source(src: &str, base_dir: &Path) -> String {
if src.is_empty() {
return src.to_string();
}
let expanded = if let Some(rest) = src.strip_prefix("~/") {
match home_dir() {
Some(home) => home.join(rest).to_string_lossy().into_owned(),
None => src.to_string(),
}
} else if src == "~" {
home_dir()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_else(|| src.to_string())
} else {
src.to_string()
};
if Path::new(&expanded).is_absolute() {
expanded
} else {
base_dir.join(&expanded).to_string_lossy().into_owned()
}
}
fn home_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.filter(|v| !v.is_empty())
.map(std::path::PathBuf::from)
}
pub(super) fn resolve_links(service: &Service, file: &ComposeFile, project: &str) -> Vec<String> {
let mut links: Vec<String> = service
.links
.iter()
.map(|link| {
let (target, alias) = link.split_once(':').unwrap_or((link, link));
let container = file
.services
.get(target)
.map(|svc| {
svc.container_name
.clone()
.unwrap_or_else(|| format!("{project}-{target}"))
})
.unwrap_or_else(|| target.to_string());
format!("{container}:{alias}")
})
.collect();
links.extend(service.external_links.iter().cloned());
links
}
pub(crate) fn config_hash(service: &Service) -> String {
use sha2::{Digest, Sha256};
let serialized = serde_json::to_value(service)
.and_then(|v| serde_json::to_vec(&v))
.unwrap_or_default();
Sha256::digest(&serialized)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub(super) fn build_env(service: &Service, base_dir: &Path) -> Result<Vec<String>> {
let entries = service.env_file.to_entries();
let env_file_vars = if !entries.is_empty() {
env_file::load_env_file_entries(&entries, base_dir)?
} else {
HashMap::new()
};
Ok(env_file::merge_env(
service.environment.to_map(),
env_file_vars,
))
}
#[cfg(test)]
mod tests {
use super::{config_hash, resolve_links, resolve_volume_name};
use crate::parse_str;
#[test]
fn links_resolve_to_container_names_external_links_verbatim() {
let file = parse_str(
"services:\n db:\n image: x\n web:\n image: x\n links:\n - db\n - db:primary\n external_links:\n - legacy_db:db\n",
)
.unwrap();
let links = resolve_links(&file.services["web"], &file, "proj");
assert!(links.contains(&"proj-db:db".to_string()));
assert!(links.contains(&"proj-db:primary".to_string()));
assert!(links.contains(&"legacy_db:db".to_string()));
}
#[test]
fn links_honour_custom_container_name() {
let file = parse_str(
"services:\n db:\n image: x\n container_name: my-db\n web:\n image: x\n links:\n - db\n",
)
.unwrap();
let links = resolve_links(&file.services["web"], &file, "proj");
assert_eq!(links, vec!["my-db:db".to_string()]);
}
#[test]
#[cfg(unix)]
fn bind_source_resolution() {
use super::resolve_bind_source;
use std::path::Path;
let base = Path::new("/srv/app");
assert_eq!(resolve_bind_source("/abs/path", base), "/abs/path");
assert_eq!(resolve_bind_source("./data", base), "/srv/app/./data");
assert_eq!(resolve_bind_source("data", base), "/srv/app/data");
std::env::set_var("HOME", "/home/u");
assert_eq!(resolve_bind_source("~/x", base), "/home/u/x");
assert_eq!(resolve_bind_source("~", base), "/home/u");
}
#[test]
fn volume_name_resolution() {
let f = parse_str(
"services:\n s:\n image: x\nvolumes:\n data:\n ext:\n external: true\n custom:\n name: my-vol\n",
)
.unwrap();
assert_eq!(resolve_volume_name("data", "proj", &f), "proj_data");
assert_eq!(resolve_volume_name("ext", "proj", &f), "ext");
assert_eq!(resolve_volume_name("custom", "proj", &f), "my-vol");
assert_eq!(resolve_volume_name("anon", "proj", &f), "anon");
}
#[test]
fn config_hash_is_stable_and_sensitive() {
let a = parse_str("services:\n web:\n image: nginx:1.27\n").unwrap();
let b = parse_str("services:\n web:\n image: nginx:1.27\n").unwrap();
let c = parse_str("services:\n web:\n image: nginx:1.28\n").unwrap();
let ha = config_hash(&a.services["web"]);
let hb = config_hash(&b.services["web"]);
let hc = config_hash(&c.services["web"]);
assert_eq!(ha, hb, "same config produces the same hash");
assert_ne!(ha, hc, "a changed image produces a different hash");
assert_eq!(ha.len(), 64, "sha-256 hex is 64 chars");
}
#[test]
fn config_hash_stable_despite_map_field_order() {
let a = parse_str(
"services:\n web:\n image: x\n storage_opt:\n size: \"10G\"\n foo: bar\n baz: qux\n",
)
.unwrap();
let b = parse_str(
"services:\n web:\n image: x\n storage_opt:\n baz: qux\n size: \"10G\"\n foo: bar\n",
)
.unwrap();
assert_eq!(
config_hash(&a.services["web"]),
config_hash(&b.services["web"]),
"hash must be independent of storage_opt key order",
);
}
}