use std::collections::HashMap;
use std::io::BufRead;
use std::path::Path;
pub fn load_env(env_file: Option<&Path>) -> HashMap<String, String> {
let mut env = HashMap::new();
if let Some(path) = env_file
&& let Ok(vars) = load_dotenv(path)
{
env.extend(vars);
}
for (key, value) in std::env::vars() {
env.insert(key, value);
}
env
}
fn load_dotenv(path: &Path) -> std::io::Result<HashMap<String, String>> {
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut vars = HashMap::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
vars.insert(key.to_owned(), value.to_owned());
}
}
Ok(vars)
}
pub fn substitute_env_vars(input: &str, env: &HashMap<String, String>) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' && chars.peek() == Some(&'{') {
chars.next(); let mut var_name = String::new();
let mut closed = false;
for ch in chars.by_ref() {
if ch == '}' {
closed = true;
break;
}
var_name.push(ch);
}
if closed {
if let Some(value) = env.get(&var_name) {
result.push_str(value);
}
} else {
result.push('$');
result.push('{');
result.push_str(&var_name);
}
} else {
result.push(ch);
}
}
result
}
pub fn substitute_in_value(
value: serde_yaml::Value,
env: &HashMap<String, String>,
) -> serde_yaml::Value {
match value {
serde_yaml::Value::String(s) => serde_yaml::Value::String(substitute_env_vars(&s, env)),
serde_yaml::Value::Mapping(map) => {
let mut out = serde_yaml::Mapping::new();
for (k, v) in map {
out.insert(k, substitute_in_value(v, env));
}
serde_yaml::Value::Mapping(out)
}
serde_yaml::Value::Sequence(seq) => serde_yaml::Value::Sequence(
seq.into_iter()
.map(|v| substitute_in_value(v, env))
.collect(),
),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_substitution() {
let env = HashMap::from([("FOO".into(), "bar".into())]);
assert_eq!(substitute_env_vars("${FOO}", &env), "bar");
assert_eq!(
substitute_env_vars("prefix-${FOO}-suffix", &env),
"prefix-bar-suffix"
);
}
#[test]
fn missing_var_becomes_empty() {
let env = HashMap::new();
assert_eq!(substitute_env_vars("${MISSING}", &env), "");
}
#[test]
fn composable_substitution() {
let env = HashMap::from([
("HOST".into(), "api.example.com".into()),
("PORT".into(), "8080".into()),
]);
assert_eq!(
substitute_env_vars("https://${HOST}:${PORT}/v1", &env),
"https://api.example.com:8080/v1"
);
}
#[test]
fn malformed_pattern_emitted_literally() {
let env = HashMap::new();
assert_eq!(substitute_env_vars("${UNCLOSED", &env), "${UNCLOSED");
}
#[test]
fn no_substitution_needed() {
let env = HashMap::new();
assert_eq!(substitute_env_vars("plain string", &env), "plain string");
}
#[test]
fn yaml_value_substitution() {
let env = HashMap::from([("KEY".into(), "secret".into())]);
let input = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("api_key".into()),
serde_yaml::Value::String("${KEY}".into()),
);
m.insert(
serde_yaml::Value::String("port".into()),
serde_yaml::Value::Number(8080.into()),
);
m
});
let output = substitute_in_value(input, &env);
if let serde_yaml::Value::Mapping(m) = output {
assert_eq!(
m.get(&serde_yaml::Value::String("api_key".into())),
Some(&serde_yaml::Value::String("secret".into()))
);
assert_eq!(
m.get(&serde_yaml::Value::String("port".into())),
Some(&serde_yaml::Value::Number(8080.into()))
);
} else {
panic!("expected mapping");
}
}
}