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;