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"));
}