use std::collections::{HashMap, HashSet};
use crate::error::{ConfigError, ConfigResult};
pub fn interpolate<F>(template: &str, resolver: &F) -> ConfigResult<String>
where
F: Fn(&str) -> Option<String>,
{
let mut visited = HashSet::new();
interpolate_inner(template, resolver, &mut visited)
}
pub fn interpolate_tracked<F>(
template: &str,
resolver: &F,
is_sensitive: bool,
) -> ConfigResult<InterpolationResult>
where
F: Fn(&str) -> Option<String>,
{
let mut visited = HashSet::new();
let mut referenced_vars = HashSet::new();
let mut sensitive_refs = HashSet::new();
let result = interpolate_inner_tracked(
template,
resolver,
&mut visited,
&mut referenced_vars,
&mut sensitive_refs,
is_sensitive,
)?;
Ok(InterpolationResult {
value: result,
referenced_vars,
sensitive_refs,
is_sensitive,
})
}
#[derive(Debug, Clone)]
pub struct InterpolationResult {
pub value: String,
pub referenced_vars: HashSet<String>,
pub sensitive_refs: HashSet<String>,
pub is_sensitive: bool,
}
impl InterpolationResult {
pub fn has_sensitive_refs(&self) -> bool {
!self.sensitive_refs.is_empty()
}
pub fn referenced_vars(&self) -> impl Iterator<Item = &String> {
self.referenced_vars.iter()
}
pub fn referenced(&self, var: &str) -> bool {
self.referenced_vars.contains(var)
}
}
fn interpolate_inner_impl<F>(
template: &str,
resolver: &F,
visited: &mut HashSet<String>,
referenced_vars: &mut Option<HashSet<String>>,
sensitive_refs: &mut Option<HashSet<String>>,
is_sensitive: bool,
) -> ConfigResult<String>
where
F: Fn(&str) -> Option<String>,
{
let mut result = String::with_capacity(template.len().saturating_mul(2).max(128));
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
i += 2;
let mut var_content = String::new();
let mut depth = 1usize;
while i < bytes.len() && depth > 0 {
let cb = bytes[i];
if cb == b'{' {
depth += 1;
var_content.push('{');
} else if cb == b'}' {
depth -= 1;
if depth > 0 {
var_content.push('}');
}
} else if cb < 128 {
var_content.push(cb as char);
} else {
let c = template[i..].chars().next().unwrap();
var_content.push(c);
i += c.len_utf8() - 1;
}
i += 1;
}
if depth > 0 {
return Err(ConfigError::InterpolationError {
variable: var_content,
message: "unterminated variable reference".to_string(),
});
}
let (var_name, default_value) = parse_var_content(&var_content)?;
if let Some(ref_vars) = referenced_vars.as_mut() {
ref_vars.insert(var_name.to_string());
}
if is_sensitive {
if let Some(sens_refs) = sensitive_refs.as_mut() {
sens_refs.insert(var_name.to_string());
}
}
if visited.contains(var_name) {
return Err(ConfigError::CircularReference {
path: var_name.to_string(),
});
}
let value = if let Some(val) = (*resolver)(var_name) {
val
} else if let Some(default) = default_value {
visited.insert(var_name.to_string());
let resolved = interpolate_inner_impl(
default,
resolver,
visited,
referenced_vars,
sensitive_refs,
is_sensitive,
)?;
visited.remove(var_name);
resolved
} else {
return Err(ConfigError::InterpolationError {
variable: var_name.to_string(),
message: "variable not found and no default provided".to_string(),
});
};
visited.insert(var_name.to_string());
let interpolated = interpolate_inner_impl(
&value,
resolver,
visited,
referenced_vars,
sensitive_refs,
is_sensitive,
)?;
visited.remove(var_name);
result.push_str(&interpolated);
} else {
if b < 128 {
result.push(b as char);
i += 1;
} else {
let c = template[i..].chars().next().unwrap();
result.push(c);
i += c.len_utf8();
}
}
}
Ok(result)
}
fn interpolate_inner<F>(
template: &str,
resolver: &F,
visited: &mut HashSet<String>,
) -> ConfigResult<String>
where
F: Fn(&str) -> Option<String>,
{
let mut ref_vars = None;
let mut sens_refs = None;
interpolate_inner_impl(
template,
resolver,
visited,
&mut ref_vars,
&mut sens_refs,
false,
)
}
fn interpolate_inner_tracked<F>(
template: &str,
resolver: &F,
visited: &mut HashSet<String>,
referenced_vars: &mut HashSet<String>,
sensitive_refs: &mut HashSet<String>,
is_sensitive: bool,
) -> ConfigResult<String>
where
F: Fn(&str) -> Option<String>,
{
let taken_refs = std::mem::take(referenced_vars);
let taken_sens = std::mem::take(sensitive_refs);
let mut ref_vars_opt = Some(taken_refs);
let mut sens_refs_opt = Some(taken_sens);
let result = interpolate_inner_impl(
template,
resolver,
visited,
&mut ref_vars_opt,
&mut sens_refs_opt,
is_sensitive,
)?;
if let Some(rv) = ref_vars_opt {
*referenced_vars = rv;
}
if let Some(sr) = sens_refs_opt {
*sensitive_refs = sr;
}
Ok(result)
}
fn parse_var_content(content: &str) -> ConfigResult<(&str, Option<&str>)> {
let content = content.trim();
let bytes = content.as_bytes();
let mut depth = 0;
let mut colon_pos = None;
let mut colon_len = 1usize;
for (i, c) in content.char_indices() {
match c {
'$' if bytes.get(i + 1) == Some(&b'{') => {
depth += 1;
}
'}' if depth > 0 => {
depth -= 1;
}
':' if depth == 0 && colon_pos.is_none() => {
colon_pos = Some(i);
if bytes.get(i + 1) == Some(&b'-') {
colon_len = 2;
}
}
_ => {}
}
}
if let Some(pos) = colon_pos {
let name = content[..pos].trim();
let default = &content[pos + colon_len..];
validate_var_name(name)?;
Ok((name, Some(default)))
} else {
validate_var_name(content)?;
Ok((content, None))
}
}
fn validate_var_name(name: &str) -> ConfigResult<()> {
if name.is_empty() {
return Err(ConfigError::InterpolationError {
variable: "".to_string(),
message: "empty variable name".to_string(),
});
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return Err(ConfigError::InterpolationError {
variable: name.to_string(),
message: "variable name must start with letter or underscore".to_string(),
});
}
for c in chars {
if !c.is_ascii_alphanumeric() && c != '_' {
return Err(ConfigError::InterpolationError {
variable: name.to_string(),
message: "variable name can only contain letters, digits, and underscores"
.to_string(),
});
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct InterpolationConfig {
pub max_depth: usize,
pub allow_unresolved: bool,
pub sensitive_vars: HashSet<String>,
pub warn_sensitive_interpolation: bool,
}
impl Default for InterpolationConfig {
fn default() -> Self {
Self {
max_depth: 10,
allow_unresolved: false,
sensitive_vars: HashSet::new(),
warn_sensitive_interpolation: true,
}
}
}
impl InterpolationConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_sensitive_var(mut self, var: impl Into<String>) -> Self {
self.sensitive_vars.insert(var.into());
self
}
pub fn with_warn_sensitive(mut self, warn: bool) -> Self {
self.warn_sensitive_interpolation = warn;
self
}
pub fn is_sensitive(&self, var: &str) -> bool {
self.sensitive_vars.contains(var)
}
}
#[derive(Debug, Default)]
pub struct InterpolationContext {
all_referenced: HashSet<String>,
sensitive_references: HashMap<String, String>, warnings: Vec<InterpolationWarning>,
}
impl InterpolationContext {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, field_name: &str, result: &InterpolationResult) {
self.all_referenced
.extend(result.referenced_vars.iter().cloned());
if result.is_sensitive {
for var in &result.referenced_vars {
self.sensitive_references
.insert(var.clone(), field_name.to_string());
}
}
}
pub fn add_warning(&mut self, warning: InterpolationWarning) {
self.warnings.push(warning);
}
pub fn warnings(&self) -> &[InterpolationWarning] {
&self.warnings
}
pub fn is_sensitive_ref(&self, var: &str) -> bool {
self.sensitive_references.contains_key(var)
}
pub fn sensitive_ref_field(&self, var: &str) -> Option<&str> {
self.sensitive_references.get(var).map(|s| s.as_str())
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
#[derive(Debug, Clone)]
pub enum InterpolationWarning {
SensitiveFieldInterpolation {
field: String,
vars: Vec<String>,
},
SensitiveVarReferenced {
var: String,
field: String,
},
CircularReferenceResolved {
var: String,
},
}
impl std::fmt::Display for InterpolationWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SensitiveFieldInterpolation { field, vars } => {
write!(
f,
"Sensitive field '{}' uses interpolation with variables: {}",
field,
vars.join(", ")
)
}
Self::SensitiveVarReferenced { var, field } => {
write!(
f,
"Sensitive variable '{}' was referenced from field '{}'",
var, field
)
}
Self::CircularReferenceResolved { var } => {
write!(f, "Circular reference resolved for variable '{}'", var)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resolver<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key| {
vars.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string())
}
}
#[test]
fn test_simple_interpolation() {
let r = resolver(&[("HOST", "localhost")]);
let result = interpolate("Server: ${HOST}", &r).unwrap();
assert_eq!(result, "Server: localhost");
}
#[test]
fn test_default_value() {
let r = resolver(&[]);
let result = interpolate("Port: ${PORT:8080}", &r).unwrap();
assert_eq!(result, "Port: 8080");
}
#[test]
fn test_default_value_with_dash() {
let r = resolver(&[]);
let result = interpolate("Port: ${PORT:-8080}", &r).unwrap();
assert_eq!(result, "Port: 8080");
}
#[test]
fn test_env_overrides_default() {
let r = resolver(&[("PORT", "443")]);
let result = interpolate("Port: ${PORT:8080}", &r).unwrap();
assert_eq!(result, "Port: 443");
}
#[test]
fn test_multiple_references() {
let r = resolver(&[("HOST", "localhost"), ("PORT", "8080")]);
let result = interpolate("${HOST}:${PORT}", &r).unwrap();
assert_eq!(result, "localhost:8080");
}
#[test]
fn test_nested_reference() {
let r = resolver(&[("HOST", "${DOMAIN}"), ("DOMAIN", "example.com")]);
let result = interpolate("Server: ${HOST}", &r).unwrap();
assert_eq!(result, "Server: example.com");
}
#[test]
fn test_circular_reference() {
let r = resolver(&[("A", "${B}"), ("B", "${A}")]);
let result = interpolate("${A}", &r);
assert!(matches!(result, Err(ConfigError::CircularReference { .. })));
}
#[test]
fn test_missing_variable() {
let r = resolver(&[]);
let result = interpolate("${UNDEFINED}", &r);
assert!(matches!(
result,
Err(ConfigError::InterpolationError { .. })
));
}
#[test]
fn test_missing_variable_with_default() {
let r = resolver(&[]);
let result = interpolate("${UNDEFINED:default_value}", &r).unwrap();
assert_eq!(result, "default_value");
}
#[test]
fn test_empty_default() {
let r = resolver(&[]);
let result = interpolate("${VAR:}", &r).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_literal_dollar() {
let r = resolver(&[]);
let result = interpolate("Cost: $100", &r).unwrap();
assert_eq!(result, "Cost: $100");
}
#[test]
fn test_url_in_default() {
let r = resolver(&[]);
let result = interpolate("${URL:http://localhost:8080}", &r).unwrap();
assert_eq!(result, "http://localhost:8080");
}
#[test]
fn test_nested_in_default() {
let r = resolver(&[("BASE", "localhost")]);
let result = interpolate("${URL:http://${BASE}:8080}", &r).unwrap();
assert_eq!(result, "http://localhost:8080");
}
#[test]
fn test_invalid_var_name() {
let r = resolver(&[]);
let result = interpolate("${123invalid}", &r);
assert!(matches!(
result,
Err(ConfigError::InterpolationError { .. })
));
}
#[test]
fn test_unterminated_reference() {
let r = resolver(&[]);
let result = interpolate("${VAR", &r);
assert!(matches!(
result,
Err(ConfigError::InterpolationError { .. })
));
}
#[test]
fn test_interpolate_tracked() {
let r = resolver(&[("HOST", "localhost"), ("PORT", "8080")]);
let result = interpolate_tracked("${HOST}:${PORT}", &r, false).unwrap();
assert_eq!(result.value, "localhost:8080");
assert!(result.referenced("HOST"));
assert!(result.referenced("PORT"));
assert!(!result.is_sensitive);
}
#[test]
fn test_sensitive_interpolation() {
let r = resolver(&[("API_KEY", "secret123")]);
let result = interpolate_tracked("${API_KEY}", &r, true).unwrap();
assert_eq!(result.value, "secret123");
assert!(result.referenced("API_KEY"));
assert!(result.has_sensitive_refs());
assert!(result.is_sensitive);
}
#[test]
fn test_interpolation_context() {
let r = resolver(&[("HOST", "localhost"), ("API_KEY", "secret")]);
let mut ctx = InterpolationContext::new();
let result = interpolate_tracked("${HOST}", &r, false).unwrap();
ctx.record("server_host", &result);
let result = interpolate_tracked("${API_KEY}", &r, true).unwrap();
ctx.record("api_key", &result);
assert!(ctx.is_sensitive_ref("API_KEY"));
assert!(!ctx.is_sensitive_ref("HOST"));
assert_eq!(ctx.sensitive_ref_field("API_KEY"), Some("api_key"));
}
#[test]
fn test_interpolation_warning() {
let warning = InterpolationWarning::SensitiveFieldInterpolation {
field: "password".to_string(),
vars: vec!["DB_PASSWORD".to_string()],
};
assert!(warning.to_string().contains("password"));
assert!(warning.to_string().contains("DB_PASSWORD"));
}
#[test]
fn test_config_sensitive_vars() {
let config = InterpolationConfig::new()
.with_sensitive_var("API_KEY")
.with_sensitive_var("DB_PASSWORD");
assert!(config.is_sensitive("API_KEY"));
assert!(config.is_sensitive("DB_PASSWORD"));
assert!(!config.is_sensitive("HOST"));
}
#[test]
fn test_interpolate_empty_template() {
let result = interpolate("", &|_| None::<String>);
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_interpolate_default_values() {
let result = interpolate("${PORT:8080}", &|_| None::<String>);
assert_eq!(result.unwrap(), "8080");
let result = interpolate("${HOST:localhost}", &|_| None::<String>);
assert_eq!(result.unwrap(), "localhost");
}
#[test]
fn test_nested_default_value() {
let result = interpolate("${outer:${inner:-fallback}}", &|_| None::<String>);
assert_eq!(result.unwrap(), "fallback");
let result = interpolate("${outer:${inner:-fallback}}", &|var: &str| {
if var == "inner" {
Some("value".to_string())
} else {
None
}
});
assert_eq!(result.unwrap(), "value");
let result = interpolate("${outer:${inner:-fallback}}", &|var: &str| {
if var == "outer" {
Some("outer_val".to_string())
} else {
None
}
});
assert_eq!(result.unwrap(), "outer_val");
}
}