use std::fmt;
pub fn resolve<F>(template: &str, env: F) -> Result<String, MissingEnvVar>
where
F: Fn(&str) -> Option<String>,
{
let mut out = String::with_capacity(template.len());
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if let Some(end) = bytes[i + 2..].iter().position(|&b| b == b'}') {
let name_start = i + 2;
let name_end = name_start + end;
let name = &template[name_start..name_end];
if is_valid_var_name(name) {
match env(name) {
Some(v) => {
out.push_str(&v);
i = name_end + 1;
continue;
}
None => return Err(MissingEnvVar(name.to_string())),
}
}
}
}
out.push(bytes[i] as char);
i += 1;
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingEnvVar(pub String);
impl fmt::Display for MissingEnvVar {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "missing environment variable: {}", self.0)
}
}
impl std::error::Error for MissingEnvVar {}
fn is_valid_var_name(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_uppercase() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
#[must_use]
pub fn references(template: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$'
&& i + 1 < bytes.len()
&& bytes[i + 1] == b'{'
&& let Some(end) = bytes[i + 2..].iter().position(|&b| b == b'}')
{
let name = &template[i + 2..i + 2 + end];
if is_valid_var_name(name) {
out.push(name.to_string());
i = i + 2 + end + 1;
continue;
}
}
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<_, _> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |k: &str| map.get(k).cloned()
}
#[test]
fn resolves_single_var() {
let r = resolve("host=x password=${PW}", env_from(&[("PW", "secret")])).unwrap();
assert_eq!(r, "host=x password=secret");
}
#[test]
fn resolves_multiple_vars() {
let r = resolve(
"host=${H} user=${U} password=${P}",
env_from(&[("H", "db.example.com"), ("U", "repl"), ("P", "secret")]),
)
.unwrap();
assert_eq!(r, "host=db.example.com user=repl password=secret");
}
#[test]
fn fails_on_missing_var() {
let err = resolve("password=${MISSING}", env_from(&[])).unwrap_err();
assert_eq!(err.0, "MISSING");
}
#[test]
fn template_without_vars_is_identity() {
let r = resolve("host=x dbname=app", env_from(&[])).unwrap();
assert_eq!(r, "host=x dbname=app");
}
#[test]
fn invalid_var_shapes_are_literal() {
let r = resolve("foo ${lowercase} bar", env_from(&[])).unwrap();
assert_eq!(r, "foo ${lowercase} bar");
}
#[test]
fn unclosed_brace_is_literal() {
let r = resolve("password=${UNCLOSED no end", env_from(&[("UNCLOSED", "x")])).unwrap();
assert_eq!(r, "password=${UNCLOSED no end");
}
#[test]
fn references_lists_all_vars_in_order() {
let r = references("host=${H} user=${U} password=${P}");
assert_eq!(r, vec!["H", "U", "P"]);
}
#[test]
fn references_skips_invalid_names() {
let r = references("foo ${bad} ${GOOD} ${bad2}");
assert_eq!(r, vec!["GOOD"]);
}
#[test]
fn underscores_allowed_in_var_names() {
let r = resolve("password=${MY_VAR_2}", env_from(&[("MY_VAR_2", "x")])).unwrap();
assert_eq!(r, "password=x");
}
}