mod parse;
use std::collections::HashMap;
use std::path::Path;
use crate::error::{ComposeError, Result};
use parse::{collect_var_name, is_var_start, parse_braced_var, resolve_modifier};
const MAX_INTERP_DEPTH: usize = 64;
pub fn substitute(input: &str, vars: &HashMap<String, String>) -> Result<String> {
substitute_depth(input, vars, 0)
}
pub(super) fn substitute_depth(
input: &str,
vars: &HashMap<String, String>,
depth: usize,
) -> Result<String> {
if depth > MAX_INTERP_DEPTH {
return Err(ComposeError::InvalidSubstitution(format!(
"interpolation nesting too deep (more than {MAX_INTERP_DEPTH} levels)"
)));
}
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '$' {
out.push(ch);
continue;
}
match chars.peek() {
None => {
out.push('$');
}
Some('$') => {
chars.next();
out.push('$');
}
Some('{') => {
chars.next();
let (var, modifier) = parse_braced_var(&mut chars)?;
let value = resolve_modifier(var, modifier, vars, depth)?;
out.push_str(&value);
}
Some(c) if is_var_start(*c) => {
let var = collect_var_name(&mut chars);
let value = match vars.get(&var) {
Some(v) => v.clone(),
None => {
tracing::warn!(
"The {var} variable is not set. Defaulting to a blank string."
);
String::new()
}
};
out.push_str(&value);
}
Some(_) => {
out.push('$');
}
}
}
Ok(out)
}
pub fn load_dotenv(dir: &Path) -> HashMap<String, String> {
let path = dir.join(".env");
let Ok(content) = crate::filesystem::read_to_string_capped(&path) else {
return HashMap::new();
};
let mut map = HashMap::new();
for (key, value) in crate::dotenv::parse(&content) {
if std::env::var(&key).is_ok() {
continue;
}
map.insert(key, value);
}
map
}
pub fn build_vars(dir: &Path) -> HashMap<String, String> {
let mut vars: HashMap<String, String> = std::env::vars().collect();
for (k, v) in load_dotenv(dir) {
vars.entry(k).or_insert(v);
}
vars
}
pub fn build_vars_with_env_files(dir: &Path, extra: &[String]) -> HashMap<String, String> {
build_vars_with_env_files_inner(dir, extra, false).unwrap_or_default()
}
pub fn build_vars_with_env_files_strict(
dir: &Path,
extra: &[String],
) -> Result<HashMap<String, String>> {
build_vars_with_env_files_inner(dir, extra, true)
}
fn first_disallowed_control_char(value: &str) -> Option<char> {
value
.chars()
.find(|&c| c.is_control() && !matches!(c, '\t' | '\n' | '\r'))
}
fn build_vars_with_env_files_inner(
dir: &Path,
extra: &[String],
strict: bool,
) -> Result<HashMap<String, String>> {
if extra.is_empty() {
return Ok(build_vars(dir));
}
let mut file_vars: HashMap<String, String> = HashMap::new();
for path in extra {
let abs = if std::path::Path::new(path).is_absolute() {
std::path::PathBuf::from(path)
} else {
dir.join(path)
};
let content = match crate::filesystem::read_to_string_capped(&abs) {
Ok(content) => content,
Err(e) => {
if strict {
return Err(crate::error::ComposeError::EnvFile(format!(
"env file not found: {} ({e})",
abs.display()
)));
}
continue;
}
};
let pairs = if strict {
crate::dotenv::parse_strict(&content)?
} else {
crate::dotenv::parse(&content)
};
for (key, value) in pairs {
if strict {
if let Some(bad) = first_disallowed_control_char(&value) {
return Err(crate::error::ComposeError::EnvFile(format!(
"env file {}: value of '{key}' contains a disallowed control \
character ({}); remove it before use",
abs.display(),
bad.escape_default(),
)));
}
}
file_vars.insert(key, value);
}
}
let mut vars: HashMap<String, String> = std::env::vars().collect();
for (k, v) in file_vars {
vars.entry(k).or_insert(v);
}
Ok(vars)
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn plain_text_unchanged() {
assert_eq!(
substitute("hello world", &vars(&[])).unwrap(),
"hello world"
);
}
#[test]
fn dollar_at_end_emitted_literally() {
assert_eq!(substitute("price$", &vars(&[])).unwrap(), "price$");
}
#[test]
fn double_dollar_becomes_single() {
assert_eq!(substitute("$$", &vars(&[])).unwrap(), "$");
}
#[test]
fn unbraced_var_set_expands() {
assert_eq!(substitute("$FOO", &vars(&[("FOO", "bar")])).unwrap(), "bar");
}
#[test]
fn unbraced_var_unset_expands_to_empty() {
assert_eq!(substitute("$MISSING", &vars(&[])).unwrap(), "");
}
#[test]
fn unbraced_var_followed_by_non_ident() {
assert_eq!(substitute("$FOO!", &vars(&[("FOO", "x")])).unwrap(), "x!");
}
#[test]
fn braced_var_set_expands() {
assert_eq!(
substitute("${FOO}", &vars(&[("FOO", "val")])).unwrap(),
"val"
);
}
#[test]
fn braced_var_unset_expands_to_empty() {
assert_eq!(substitute("${MISSING}", &vars(&[])).unwrap(), "");
}
#[test]
fn default_if_unset_or_empty_when_unset() {
assert_eq!(
substitute("${X:-fallback}", &vars(&[])).unwrap(),
"fallback"
);
}
#[test]
fn default_if_unset_or_empty_when_empty() {
assert_eq!(
substitute("${X:-fallback}", &vars(&[("X", "")])).unwrap(),
"fallback"
);
}
#[test]
fn default_if_unset_or_empty_when_set() {
assert_eq!(
substitute("${X:-fallback}", &vars(&[("X", "real")])).unwrap(),
"real"
);
}
#[test]
fn default_if_unset_when_unset() {
assert_eq!(substitute("${X-fallback}", &vars(&[])).unwrap(), "fallback");
}
#[test]
fn default_if_unset_when_empty_keeps_empty() {
assert_eq!(
substitute("${X-fallback}", &vars(&[("X", "")])).unwrap(),
""
);
}
#[test]
fn default_if_unset_when_set() {
assert_eq!(
substitute("${X-fallback}", &vars(&[("X", "v")])).unwrap(),
"v"
);
}
#[test]
fn alt_if_set_and_nonempty_when_unset() {
assert_eq!(substitute("${X:+alt}", &vars(&[])).unwrap(), "");
}
#[test]
fn alt_if_set_and_nonempty_when_empty() {
assert_eq!(substitute("${X:+alt}", &vars(&[("X", "")])).unwrap(), "");
}
#[test]
fn alt_if_set_and_nonempty_when_set() {
assert_eq!(
substitute("${X:+alt}", &vars(&[("X", "v")])).unwrap(),
"alt"
);
}
#[test]
fn alt_if_set_when_unset() {
assert_eq!(substitute("${X+alt}", &vars(&[])).unwrap(), "");
}
#[test]
fn alt_if_set_when_empty_returns_alt() {
assert_eq!(substitute("${X+alt}", &vars(&[("X", "")])).unwrap(), "alt");
}
#[test]
fn error_if_unset_or_empty_when_unset() {
assert!(substitute("${X:?required}", &vars(&[])).is_err());
}
#[test]
fn error_if_unset_or_empty_when_empty() {
assert!(substitute("${X:?required}", &vars(&[("X", "")])).is_err());
}
#[test]
fn error_if_unset_or_empty_when_set() {
assert_eq!(
substitute("${X:?required}", &vars(&[("X", "ok")])).unwrap(),
"ok"
);
}
#[test]
fn error_if_unset_when_unset() {
assert!(substitute("${X?required}", &vars(&[])).is_err());
}
#[test]
fn error_if_unset_when_empty_returns_empty() {
assert_eq!(
substitute("${X?required}", &vars(&[("X", "")])).unwrap(),
""
);
}
#[test]
fn nested_default_is_interpolated() {
assert_eq!(
substitute("${FOO:-${BAR}}", &vars(&[("BAR", "b")])).unwrap(),
"b"
);
}
#[test]
fn nested_chained_default_falls_through() {
assert_eq!(
substitute("${FOO:-${BAR:-baz}}", &vars(&[])).unwrap(),
"baz"
);
}
#[test]
fn nested_alt_is_interpolated() {
assert_eq!(
substitute("${FOO:+${BAR}}", &vars(&[("FOO", "x"), ("BAR", "b")])).unwrap(),
"b"
);
}
#[test]
fn nested_default_with_trailing_text() {
assert_eq!(
substitute("${FOO:-${BAR}}/tail", &vars(&[("BAR", "b")])).unwrap(),
"b/tail"
);
}
#[test]
fn empty_name_is_error() {
assert!(substitute("${}", &vars(&[])).is_err());
}
#[test]
fn digit_leading_name_is_error() {
assert!(substitute("${1BAD}", &vars(&[])).is_err());
}
#[test]
fn unterminated_modifier_is_error() {
assert!(substitute("${TAG:-latest\nmore", &vars(&[])).is_err());
}
#[test]
fn deeply_nested_defaults_error_instead_of_overflowing() {
let depth = MAX_INTERP_DEPTH + 50;
let mut s = String::new();
for _ in 0..depth {
s.push_str("${A:-");
}
s.push('x');
for _ in 0..depth {
s.push('}');
}
let err = substitute(&s, &vars(&[])).expect_err("over-deep nesting must error");
assert!(matches!(
err,
crate::error::ComposeError::InvalidSubstitution(_)
));
}
#[test]
fn moderate_nesting_still_resolves() {
assert_eq!(
substitute("${A:-${B:-${C:-deep}}}", &vars(&[])).unwrap(),
"deep"
);
}
#[test]
fn multiple_vars_in_string() {
let v = vars(&[("A", "hello"), ("B", "world")]);
assert_eq!(substitute("$A ${B}!", &v).unwrap(), "hello world!");
}
#[test]
fn load_dotenv_strips_double_quoted_value() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=\"bar\"\n").unwrap();
let map = load_dotenv(dir.path());
assert_eq!(map.get("FOO").map(|s| s.as_str()), Some("bar"));
}
#[test]
fn load_dotenv_strips_single_quoted_value() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO='bar'\n").unwrap();
let map = load_dotenv(dir.path());
assert_eq!(map.get("FOO").map(|s| s.as_str()), Some("bar"));
}
#[test]
fn load_dotenv_parses_key_value() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FOO=bar\nBAZ=qux\n").unwrap();
let map = load_dotenv(dir.path());
assert_eq!(map.get("FOO").map(|s| s.as_str()), Some("bar"));
assert_eq!(map.get("BAZ").map(|s| s.as_str()), Some("qux"));
}
#[test]
fn load_dotenv_skips_comments_and_blank_lines() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "# comment\n\nFOO=bar\n").unwrap();
let map = load_dotenv(dir.path());
assert_eq!(map.len(), 1);
assert_eq!(map["FOO"], "bar");
}
#[test]
fn load_dotenv_bare_key_is_not_set_to_empty() {
std::env::set_var("PODUP_DOTENV_ENV_PRESENT", "h");
std::env::remove_var("PODUP_DOTENV_ENV_ABSENT");
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"PODUP_DOTENV_ENV_PRESENT\nPODUP_DOTENV_ENV_ABSENT\n",
)
.unwrap();
let map = load_dotenv(dir.path());
assert!(!map.contains_key("PODUP_DOTENV_ENV_PRESENT"));
assert!(!map.contains_key("PODUP_DOTENV_ENV_ABSENT"));
std::env::remove_var("PODUP_DOTENV_ENV_PRESENT");
}
#[test]
fn load_dotenv_missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let map = load_dotenv(dir.path());
assert!(map.is_empty());
}
#[test]
fn env_file_replaces_dotenv() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".env"),
"FROM_DOTENV=base\nPODUP_TEST_SHARED=dotenv\n",
)
.unwrap();
std::fs::write(
dir.path().join("extra.env"),
"FROM_EXTRA=more\nPODUP_TEST_SHARED=extra\n",
)
.unwrap();
let vars =
build_vars_with_env_files_strict(dir.path(), &["extra.env".to_string()]).unwrap();
assert_eq!(vars.get("FROM_DOTENV"), None);
assert_eq!(vars.get("FROM_EXTRA").map(String::as_str), Some("more"));
assert_eq!(
vars.get("PODUP_TEST_SHARED").map(String::as_str),
Some("extra")
);
}
#[test]
fn later_env_file_wins() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.env"), "FROM_A=a\nSHARED=a\n").unwrap();
std::fs::write(dir.path().join("b.env"), "FROM_B=b\nSHARED=b\n").unwrap();
let vars = build_vars_with_env_files_strict(
dir.path(),
&["a.env".to_string(), "b.env".to_string()],
)
.unwrap();
assert_eq!(vars.get("FROM_A").map(String::as_str), Some("a"));
assert_eq!(vars.get("FROM_B").map(String::as_str), Some("b"));
assert_eq!(vars.get("SHARED").map(String::as_str), Some("b"));
}
#[test]
fn process_env_wins_over_env_file() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("PODUP_ENVFILE_PROCESS_WINS", "from-process");
std::fs::write(
dir.path().join("x.env"),
"PODUP_ENVFILE_PROCESS_WINS=from-file\n",
)
.unwrap();
let vars = build_vars_with_env_files_strict(dir.path(), &["x.env".to_string()]).unwrap();
assert_eq!(
vars.get("PODUP_ENVFILE_PROCESS_WINS").map(String::as_str),
Some("from-process")
);
std::env::remove_var("PODUP_ENVFILE_PROCESS_WINS");
}
#[test]
fn no_env_file_loads_dotenv() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "FROM_DOTENV=base\n").unwrap();
let vars = build_vars_with_env_files_strict(dir.path(), &[]).unwrap();
assert_eq!(vars.get("FROM_DOTENV").map(String::as_str), Some("base"));
}
#[test]
fn strict_build_vars_errors_on_missing_extra_file() {
let dir = tempfile::tempdir().unwrap();
let err =
build_vars_with_env_files_strict(dir.path(), &["absent.env".to_string()]).unwrap_err();
assert!(
matches!(err, crate::error::ComposeError::EnvFile(_)),
"expected EnvFile error, got {err:?}"
);
assert!(err.to_string().contains("env file not found"));
}
#[test]
fn strict_build_vars_errors_on_unterminated_quote() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("bad.env"), "A=\"oops\nB=keep\n").unwrap();
let err =
build_vars_with_env_files_strict(dir.path(), &["bad.env".to_string()]).unwrap_err();
assert!(matches!(err, crate::error::ComposeError::EnvFile(_)));
}
#[test]
fn lenient_build_vars_skips_missing_extra_file() {
let dir = tempfile::tempdir().unwrap();
let vars = build_vars_with_env_files(dir.path(), &["absent.env".to_string()]);
assert!(!vars.contains_key("FROM_EXTRA"));
}
#[test]
fn first_disallowed_control_char_allows_tab_newline_cr() {
assert_eq!(first_disallowed_control_char("plain value"), None);
assert_eq!(first_disallowed_control_char("a\tb\nc\rd"), None);
assert_eq!(first_disallowed_control_char("a\0b"), Some('\0'));
assert_eq!(first_disallowed_control_char("x\x1by"), Some('\x1b'));
}
#[test]
fn strict_build_vars_errors_on_control_char_value_naming_file_and_key() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("bad.env"), "SECRET=ab\0cd\n").unwrap();
let err =
build_vars_with_env_files_strict(dir.path(), &["bad.env".to_string()]).unwrap_err();
assert!(
matches!(err, crate::error::ComposeError::EnvFile(_)),
"expected EnvFile error, got {err:?}"
);
let msg = err.to_string();
assert!(msg.contains("bad.env"), "names the env file: {msg}");
assert!(msg.contains("SECRET"), "names the offending key: {msg}");
assert!(
msg.contains("control"),
"explains the control-char cause: {msg}"
);
}
#[test]
fn strict_build_vars_allows_multiline_and_tab_values() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("ok.env"),
"MULTI=\"line one\nline two\"\nTABBED=\"a\\tb\"\n",
)
.unwrap();
let vars = build_vars_with_env_files_strict(dir.path(), &["ok.env".to_string()]).unwrap();
assert_eq!(
vars.get("MULTI").map(String::as_str),
Some("line one\nline two")
);
assert_eq!(vars.get("TABBED").map(String::as_str), Some("a\tb"));
}
}