use std::collections::HashMap;
use std::path::Path;
use crate::error::{ComposeError, Result};
pub fn substitute(input: &str, vars: &HashMap<String, String>) -> Result<String> {
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)?;
out.push_str(&value);
}
Some(c) if is_var_start(*c) => {
let var = collect_var_name(&mut chars);
let value = vars.get(&var).cloned().unwrap_or_default();
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) = std::fs::read_to_string(&path) else {
return HashMap::new();
};
let mut map = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (key, value) = if let Some(eq) = trimmed.find('=') {
let k = trimmed[..eq].trim().to_string();
let v = trimmed[eq + 1..].to_string();
(k, v)
} else {
(trimmed.to_string(), String::new())
};
if key.is_empty() {
continue;
}
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> {
let mut vars = build_vars(dir);
for path in extra {
let abs = if std::path::Path::new(path).is_absolute() {
std::path::PathBuf::from(path)
} else {
dir.join(path)
};
let Ok(content) = std::fs::read_to_string(&abs) else {
continue;
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (key, value) = if let Some(eq) = trimmed.find('=') {
(
trimmed[..eq].trim().to_string(),
trimmed[eq + 1..].to_string(),
)
} else {
(trimmed.to_string(), String::new())
};
if !key.is_empty() {
vars.entry(key).or_insert(value);
}
}
}
vars
}
fn is_var_start(c: char) -> bool {
c.is_alphabetic() || c == '_'
}
fn is_var_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn collect_var_name(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut name = String::new();
while let Some(&c) = chars.peek() {
if is_var_char(c) {
name.push(c);
chars.next();
} else {
break;
}
}
name
}
#[derive(Debug)]
enum Modifier {
None,
DefaultIfUnsetOrEmpty(String),
DefaultIfUnset(String),
AltIfSetAndNonEmpty(String),
AltIfSet(String),
ErrorIfUnsetOrEmpty(String),
ErrorIfUnset(String),
}
fn parse_braced_var(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
) -> Result<(String, Modifier)> {
let mut name = String::new();
loop {
match chars.peek() {
None => {
return Ok((name, Modifier::None));
}
Some('}') => {
chars.next();
return Ok((name, Modifier::None));
}
Some(':') => {
chars.next();
let modifier = match chars.peek() {
Some('-') => {
chars.next();
Modifier::DefaultIfUnsetOrEmpty(collect_until_close(chars))
}
Some('+') => {
chars.next();
Modifier::AltIfSetAndNonEmpty(collect_until_close(chars))
}
Some('?') => {
chars.next();
Modifier::ErrorIfUnsetOrEmpty(collect_until_close(chars))
}
_ => Modifier::DefaultIfUnsetOrEmpty(collect_until_close(chars)),
};
return Ok((name, modifier));
}
Some('-') => {
chars.next();
return Ok((name, Modifier::DefaultIfUnset(collect_until_close(chars))));
}
Some('+') => {
chars.next();
return Ok((name, Modifier::AltIfSet(collect_until_close(chars))));
}
Some('?') => {
chars.next();
return Ok((name, Modifier::ErrorIfUnset(collect_until_close(chars))));
}
Some(&c) => {
name.push(c);
chars.next();
}
}
}
}
fn collect_until_close(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut buf = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
buf.push(c);
}
buf
}
fn resolve_modifier(
var: String,
modifier: Modifier,
vars: &HashMap<String, String>,
) -> Result<String> {
let value = vars.get(&var);
match modifier {
Modifier::None => Ok(value.cloned().unwrap_or_default()),
Modifier::DefaultIfUnsetOrEmpty(default) => match value {
Some(v) if !v.is_empty() => Ok(v.clone()),
_ => Ok(default),
},
Modifier::DefaultIfUnset(default) => match value {
Some(v) => Ok(v.clone()),
None => Ok(default),
},
Modifier::AltIfSetAndNonEmpty(alt) => match value {
Some(v) if !v.is_empty() => Ok(alt),
_ => Ok(String::new()),
},
Modifier::AltIfSet(alt) => match value {
Some(_) => Ok(alt),
None => Ok(String::new()),
},
Modifier::ErrorIfUnsetOrEmpty(msg) => match value {
Some(v) if !v.is_empty() => Ok(v.clone()),
_ => Err(ComposeError::RequiredVarNotSet { var, msg }),
},
Modifier::ErrorIfUnset(msg) => match value {
Some(v) => Ok(v.clone()),
None => Err(ComposeError::RequiredVarNotSet { var, msg }),
},
}
}
#[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 multiple_vars_in_string() {
let v = vars(&[("A", "hello"), ("B", "world")]);
assert_eq!(substitute("$A ${B}!", &v).unwrap(), "hello world!");
}
#[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_key_without_equals_is_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "BARE_KEY\n").unwrap();
let map = load_dotenv(dir.path());
assert_eq!(map.get("BARE_KEY").map(|s| s.as_str()), Some(""));
}
#[test]
fn load_dotenv_missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let map = load_dotenv(dir.path());
assert!(map.is_empty());
}
}