use std::collections::HashMap;
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn expand_variables(
input: &str,
args: &HashMap<String, String>,
env: &HashMap<String, String>,
) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' {
if let Some(&next) = chars.peek() {
if next == '{' {
chars.next(); let (expanded, _) = expand_braced_variable(&mut chars, args, env);
result.push_str(&expanded);
} else if next == '$' {
chars.next();
result.push('$');
} else if next.is_ascii_alphabetic() || next == '_' {
let var_name = consume_var_name(&mut chars);
if let Some(value) = lookup_variable(&var_name, args, env) {
result.push_str(&value);
} else {
result.push('$');
result.push_str(&var_name);
}
} else {
result.push(c);
}
} else {
result.push(c);
}
} else if c == '\\' {
if let Some(&next) = chars.peek() {
if next == '$' {
chars.next();
result.push('$');
} else {
result.push(c);
}
} else {
result.push(c);
}
} else {
result.push(c);
}
}
result
}
fn consume_var_name(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
let mut name = String::new();
while let Some(&c) = chars.peek() {
if c.is_ascii_alphanumeric() || c == '_' {
name.push(c);
chars.next();
} else {
break;
}
}
name
}
fn expand_braced_variable(
chars: &mut std::iter::Peekable<std::str::Chars>,
args: &HashMap<String, String>,
env: &HashMap<String, String>,
) -> (String, bool) {
let mut var_name = String::new();
let mut operator = None;
let mut default_value = String::new();
let mut in_default = false;
let mut brace_depth = 1;
while let Some(c) = chars.next() {
if c == '}' {
brace_depth -= 1;
if brace_depth == 0 {
break;
}
if in_default {
default_value.push(c);
}
} else if c == '{' {
brace_depth += 1;
if in_default {
default_value.push(c);
}
} else if !in_default && (c == ':' || c == '-' || c == '+') {
if c == ':' {
if let Some(&next) = chars.peek() {
if next == '-' || next == '+' {
chars.next();
operator = Some(format!(":{next}"));
in_default = true;
continue;
}
}
var_name.push(c);
} else if c == '-' || c == '+' {
operator = Some(c.to_string());
in_default = true;
} else {
var_name.push(c);
}
} else if in_default {
default_value.push(c);
} else {
var_name.push(c);
}
}
let value = lookup_variable(&var_name, args, env);
match operator.as_deref() {
Some(":-") => {
match value {
Some(v) if !v.is_empty() => (v, true),
_ => {
(expand_variables(&default_value, args, env), false)
}
}
}
Some("-") => {
match value {
Some(v) => (v, true),
None => (expand_variables(&default_value, args, env), false),
}
}
Some(":+") => {
match value {
Some(v) if !v.is_empty() => (expand_variables(&default_value, args, env), true),
_ => (String::new(), false),
}
}
Some("+") => {
match value {
Some(_) => (expand_variables(&default_value, args, env), true),
None => (String::new(), false),
}
}
None | Some(_) => {
match value {
Some(v) => (v, true),
None => (format!("${{{var_name}}}"), false),
}
}
}
}
fn lookup_variable(
name: &str,
args: &HashMap<String, String>,
env: &HashMap<String, String>,
) -> Option<String> {
env.get(name).cloned().or_else(|| args.get(name).cloned())
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn expand_variables_in_list(
inputs: &[String],
args: &HashMap<String, String>,
env: &HashMap<String, String>,
) -> Vec<String> {
inputs
.iter()
.map(|s| expand_variables(s, args, env))
.collect()
}
#[derive(Debug, Default, Clone)]
pub struct VariableContext {
pub build_args: HashMap<String, String>,
pub arg_defaults: HashMap<String, String>,
pub env_vars: HashMap<String, String>,
}
impl VariableContext {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_build_args(build_args: HashMap<String, String>) -> Self {
Self {
build_args,
..Default::default()
}
}
pub fn add_arg(&mut self, name: impl Into<String>, default: Option<String>) {
let name = name.into();
if let Some(default) = default {
self.arg_defaults.insert(name, default);
}
}
pub fn set_env(&mut self, name: impl Into<String>, value: impl Into<String>) {
self.env_vars.insert(name.into(), value.into());
}
#[must_use]
pub fn effective_args(&self) -> HashMap<String, String> {
let mut result = self.arg_defaults.clone();
for (k, v) in &self.build_args {
result.insert(k.clone(), v.clone());
}
result
}
#[must_use]
pub fn expand(&self, input: &str) -> String {
expand_variables(input, &self.effective_args(), &self.env_vars)
}
#[must_use]
pub fn expand_list(&self, inputs: &[String]) -> Vec<String> {
expand_variables_in_list(inputs, &self.effective_args(), &self.env_vars)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_variable() {
let mut args = HashMap::new();
args.insert("VERSION".to_string(), "1.0".to_string());
let env = HashMap::new();
assert_eq!(expand_variables("$VERSION", &args, &env), "1.0");
assert_eq!(expand_variables("${VERSION}", &args, &env), "1.0");
assert_eq!(expand_variables("v$VERSION", &args, &env), "v1.0");
assert_eq!(
expand_variables("v${VERSION}-release", &args, &env),
"v1.0-release"
);
}
#[test]
fn test_undefined_variable() {
let args = HashMap::new();
let env = HashMap::new();
assert_eq!(expand_variables("$UNDEFINED", &args, &env), "$UNDEFINED");
assert_eq!(
expand_variables("${UNDEFINED}", &args, &env),
"${UNDEFINED}"
);
}
#[test]
fn test_default_value_colon_minus() {
let args = HashMap::new();
let env = HashMap::new();
assert_eq!(expand_variables("${VERSION:-1.0}", &args, &env), "1.0");
let mut args = HashMap::new();
args.insert("VERSION".to_string(), "2.0".to_string());
assert_eq!(expand_variables("${VERSION:-1.0}", &args, &env), "2.0");
let mut args = HashMap::new();
args.insert("VERSION".to_string(), String::new());
assert_eq!(expand_variables("${VERSION:-1.0}", &args, &env), "1.0");
}
#[test]
fn test_default_value_minus() {
let args = HashMap::new();
let env = HashMap::new();
assert_eq!(expand_variables("${VERSION-1.0}", &args, &env), "1.0");
let mut args = HashMap::new();
args.insert("VERSION".to_string(), String::new());
assert_eq!(expand_variables("${VERSION-1.0}", &args, &env), "");
}
#[test]
fn test_alternate_value_colon_plus() {
let mut args = HashMap::new();
let env = HashMap::new();
assert_eq!(expand_variables("${VERSION:+set}", &args, &env), "");
args.insert("VERSION".to_string(), "1.0".to_string());
assert_eq!(expand_variables("${VERSION:+set}", &args, &env), "set");
args.insert("VERSION".to_string(), String::new());
assert_eq!(expand_variables("${VERSION:+set}", &args, &env), "");
}
#[test]
fn test_alternate_value_plus() {
let mut args = HashMap::new();
let env = HashMap::new();
assert_eq!(expand_variables("${VERSION+set}", &args, &env), "");
args.insert("VERSION".to_string(), String::new());
assert_eq!(expand_variables("${VERSION+set}", &args, &env), "set");
}
#[test]
fn test_env_takes_precedence() {
let mut args = HashMap::new();
args.insert("VAR".to_string(), "from_arg".to_string());
let mut env = HashMap::new();
env.insert("VAR".to_string(), "from_env".to_string());
assert_eq!(expand_variables("$VAR", &args, &env), "from_env");
}
#[test]
fn test_escaped_dollar() {
let args = HashMap::new();
let env = HashMap::new();
assert_eq!(expand_variables("\\$VAR", &args, &env), "$VAR");
assert_eq!(expand_variables("$$", &args, &env), "$");
}
#[test]
fn test_nested_default() {
let mut args = HashMap::new();
args.insert("DEFAULT".to_string(), "nested".to_string());
let env = HashMap::new();
assert_eq!(
expand_variables("${UNSET:-$DEFAULT}", &args, &env),
"nested"
);
}
#[test]
fn test_variable_context() {
let mut ctx = VariableContext::with_build_args({
let mut m = HashMap::new();
m.insert("BUILD_TYPE".to_string(), "release".to_string());
m
});
ctx.add_arg("VERSION", Some("1.0".to_string()));
ctx.set_env("HOME", "/app".to_string());
assert_eq!(ctx.expand("$BUILD_TYPE"), "release");
assert_eq!(ctx.expand("$VERSION"), "1.0");
assert_eq!(ctx.expand("$HOME"), "/app");
}
#[test]
fn test_build_arg_overrides_default() {
let mut ctx = VariableContext::with_build_args({
let mut m = HashMap::new();
m.insert("VERSION".to_string(), "2.0".to_string());
m
});
ctx.add_arg("VERSION", Some("1.0".to_string()));
assert_eq!(ctx.expand("$VERSION"), "2.0");
}
#[test]
fn test_complex_string() {
let mut args = HashMap::new();
args.insert("APP".to_string(), "myapp".to_string());
args.insert("VERSION".to_string(), "1.2.3".to_string());
let env = HashMap::new();
let input = "FROM registry.example.com/${APP}:${VERSION:-latest}";
assert_eq!(
expand_variables(input, &args, &env),
"FROM registry.example.com/myapp:1.2.3"
);
}
}