use std::path::{Component, Path, PathBuf};
use podup::compose::types::{ComposeFile, VolumeMount, VolumeType};
pub(super) fn resolve_bind_sources(file: &mut ComposeFile, base_dir: &Path) {
for svc in file.services.values_mut() {
for mount in &mut svc.volumes {
match mount {
VolumeMount::Short(s) => {
if let Some(rewritten) = rewrite_short_bind(s, base_dir) {
*s = rewritten;
}
}
VolumeMount::Long {
volume_type: VolumeType::Bind,
source: Some(src),
..
} => {
if let Some(abs) = absolute_bind_source(src, base_dir) {
*src = abs;
}
}
VolumeMount::Long { .. } => {}
}
}
}
}
fn rewrite_short_bind(spec: &str, base_dir: &Path) -> Option<String> {
let (src, rest) = spec.split_once(':')?;
let abs = absolute_bind_source(src, base_dir)?;
Some(format!("{abs}:{rest}"))
}
fn absolute_bind_source(src: &str, base_dir: &Path) -> Option<String> {
if !src.starts_with('.') {
return None;
}
let joined = base_dir.join(src);
let absolute = if joined.is_absolute() {
joined
} else {
std::env::current_dir().unwrap_or_default().join(joined)
};
Some(normalize_lexically(&absolute))
}
fn normalize_lexically(path: &Path) -> String {
let mut out: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
} else {
out.push(comp);
}
}
other => out.push(other),
}
}
let mut pb = PathBuf::new();
for c in out {
pb.push(c.as_os_str());
}
pb.to_string_lossy().into_owned()
}
pub(super) fn quote_yaml11_booleans(yaml: &str) -> String {
let lines: Vec<String> = yaml.lines().map(requote_bool_line).collect();
let mut joined = lines.join("\n");
if yaml.ends_with('\n') {
joined.push('\n');
}
joined
}
fn requote_bool_line(line: &str) -> String {
let Some((prefix, value)) = split_block_scalar(line) else {
return line.to_string();
};
if is_quoted(value) || !looks_like_yaml11_bool(value) {
return line.to_string();
}
format!("{prefix}'{value}'")
}
fn split_block_scalar(line: &str) -> Option<(&str, &str)> {
if let Some(idx) = line.find(": ") {
let (prefix, value) = line.split_at(idx + 2);
if !value.is_empty() {
return Some((prefix, value));
}
}
let rest = line.trim_start().strip_prefix("- ")?;
if rest.is_empty() {
return None;
}
let prefix_len = line.len() - rest.len();
Some((&line[..prefix_len], rest))
}
fn is_quoted(s: &str) -> bool {
s.starts_with('\'') || s.starts_with('"')
}
fn looks_like_yaml11_bool(s: &str) -> bool {
matches!(
s.to_ascii_lowercase().as_str(),
"y" | "yes" | "n" | "no" | "true" | "false" | "on" | "off"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn yaml11_bool_detection_is_case_insensitive_and_scoped() {
for tok in [
"yes", "Yes", "YES", "no", "on", "OFF", "y", "N", "true", "False",
] {
assert!(looks_like_yaml11_bool(tok), "{tok} should match");
}
for tok in ["hello", "yess", "0", "onoff", "", "nullish"] {
assert!(!looks_like_yaml11_bool(tok), "{tok} should not match");
}
}
#[test]
fn quote_yaml11_booleans_quotes_only_plain_bool_scalars() {
let input = "environment:\n FROM_A: yes\n FROM_B: off\n FROM_C: 'null'\n NORMAL: hello\n COLON: 'a: b'\nports:\n- on\n- '8080:80'\n";
let out = quote_yaml11_booleans(input);
assert!(out.contains("FROM_A: 'yes'"), "got: {out}");
assert!(out.contains("FROM_B: 'off'"), "got: {out}");
assert!(out.contains("FROM_C: 'null'"), "double-quoting: {out}");
assert!(out.contains("NORMAL: hello"), "got: {out}");
assert!(out.contains("COLON: 'a: b'"), "got: {out}");
assert!(out.contains("- 'on'"), "sequence bool item: {out}");
assert!(out.contains("- '8080:80'"), "got: {out}");
assert!(out.ends_with('\n'));
}
#[cfg(unix)]
#[test]
fn short_bind_source_resolves_relative_only() {
let base = Path::new("/home/user/proj");
assert_eq!(
rewrite_short_bind("./data:/data:ro", base).as_deref(),
Some("/home/user/proj/data:/data:ro")
);
assert_eq!(
rewrite_short_bind("../shared:/s", base).as_deref(),
Some("/home/user/shared:/s")
);
assert!(rewrite_short_bind("/abs:/data", base).is_none());
assert!(rewrite_short_bind("named:/data", base).is_none());
assert!(rewrite_short_bind("~/x:/data", base).is_none());
assert!(rewrite_short_bind("/data", base).is_none());
}
#[cfg(unix)]
#[test]
fn resolve_bind_sources_rewrites_short_and_long_binds() {
let mut file = podup::parse_str(
"services:\n web:\n image: nginx\n volumes:\n - ./data:/data:ro\n - type: bind\n source: ./logs\n target: /logs\n - named:/cache\n",
)
.unwrap();
resolve_bind_sources(&mut file, Path::new("/srv/app"));
let mounts = &file.services["web"].volumes;
match &mounts[0] {
VolumeMount::Short(s) => assert_eq!(s, "/srv/app/data:/data:ro"),
other => panic!("expected short, got {other:?}"),
}
match &mounts[1] {
VolumeMount::Long { source, .. } => {
assert_eq!(source.as_deref(), Some("/srv/app/logs"))
}
other => panic!("expected long bind, got {other:?}"),
}
match &mounts[2] {
VolumeMount::Short(s) => assert_eq!(s, "named:/cache"),
other => panic!("expected short, got {other:?}"),
}
}
}