use crate::{PolicyCatalog, SubstitutionError, SubstitutionPolicy, ValueResolver};
use regex::Regex;
pub struct Substituter<'a> {
policy: &'a dyn SubstitutionPolicy,
resolver: &'a dyn ValueResolver,
location: String,
}
impl<'a> Substituter<'a> {
pub fn new(
policy: &'a dyn SubstitutionPolicy,
resolver: &'a dyn ValueResolver,
location: String,
) -> Self {
Self {
policy,
resolver,
location,
}
}
pub fn substitute(&self, value: &str) -> Result<String, SubstitutionError> {
if value.contains("{{{{") || value.contains("}}}}") {
return Err(SubstitutionError::InvalidSubstitutionSyntax {
value: value.to_string(),
reason: "nested placeholders (e.g., {{VAR_{{OTHER}}}}) are not supported"
.to_string(),
});
}
let escaped = value
.replace("\\{\\{", "\x00ESCAPED_OPEN\x00")
.replace("\\}\\}", "\x00ESCAPED_CLOSE\x00");
#[allow(clippy::expect_used)]
let re = Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}")
.expect("valid regex pattern for {{VAR_NAME}} substitution");
let mut first_error: Option<SubstitutionError> = None;
let result = re
.replace_all(&escaped, |caps: ®ex::Captures| {
let var_name = &caps[1];
match self.substitute_var(var_name) {
Ok(value) => value,
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
String::new()
}
}
})
.to_string();
if let Some(err) = first_error {
return Err(err);
}
Ok(result
.replace("\x00ESCAPED_OPEN\x00", "{{")
.replace("\x00ESCAPED_CLOSE\x00", "}}"))
}
fn substitute_var(&self, var_name: &str) -> Result<String, SubstitutionError> {
self.policy.validate(var_name)?;
self.resolver.resolve(var_name, &self.location)
}
}
impl<'a> PolicyCatalog for Substituter<'a> {
type SubstitutionError = crate::SubstitutionError;
type CompositePolicy = crate::CompositePolicy;
type PatternWhitelistPolicy = crate::PatternWhitelistPolicy;
type PrefixWhitelistPolicy = crate::PrefixWhitelistPolicy;
#[cfg(any(test, feature = "test-utils"))]
type AllowAllPolicy = crate::AllowAllPolicy;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AllowAllPolicy, EnvValueResolver};
static RESOLVER: EnvValueResolver = EnvValueResolver;
fn must<T, E>(result: Result<T, E>) -> T {
result.unwrap_or_else(|_| std::process::abort())
}
fn substituter(policy: &dyn SubstitutionPolicy) -> Substituter<'_> {
Substituter::new(policy, &RESOLVER, "test_location".to_string())
}
#[test]
fn test_new() {
let policy = AllowAllPolicy;
let sub = Substituter::new(&policy, &RESOLVER, "test_file.toml".to_string());
assert!(!sub.location.is_empty());
}
#[test]
fn test_substitute() {
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = must(sub.substitute("value"));
assert_eq!(result, "value");
}
#[test]
fn test_no_substitution_needed() {
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = must(sub.substitute("hello world"));
assert_eq!(result, "hello world");
}
#[test]
fn test_single_substitution() {
std::env::set_var("TEST_VAR", "value123");
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = must(sub.substitute("prefix-{{TEST_VAR}}-suffix"));
assert_eq!(result, "prefix-value123-suffix");
}
#[test]
fn test_multiple_substitutions() {
std::env::set_var("VAR1", "hello");
std::env::set_var("VAR2", "world");
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = must(sub.substitute("{{VAR1}} {{VAR2}}"));
assert_eq!(result, "hello world");
}
#[test]
fn test_escaped_braces() {
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = must(sub.substitute(r"use \{\{VAR\}\} for substitution"));
assert_eq!(result, "use {{VAR}} for substitution");
}
#[test]
fn test_missing_variable() {
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = sub.substitute("value: {{NONEXISTENT_VAR}}");
assert!(matches!(
result,
Err(SubstitutionError::VariableNotFound { .. })
));
}
#[test]
fn test_nested_placeholder_rejected() {
let policy = AllowAllPolicy;
let sub = substituter(&policy);
let result = sub.substitute("{{VAR_{{INNER}}}}");
assert!(matches!(
result,
Err(SubstitutionError::InvalidSubstitutionSyntax { .. })
));
}
#[test]
fn test_policy_rejection() {
use crate::PrefixWhitelistPolicy;
let policy = PrefixWhitelistPolicy::new(vec!["ALLOWED_".to_string()]);
let sub = substituter(&policy);
let result = sub.substitute("{{FORBIDDEN_VAR}}");
assert!(matches!(
result,
Err(SubstitutionError::VariableRejected { .. })
));
}
}