configbuilder-pattern-substitution 0.1.0

configbuilder-pattern: substitution domain, trait contract + reference implementation. No -svc counterpart.
Documentation
use crate::{PolicyCatalog, SubstitutionError, SubstitutionPolicy, ValueResolver};
use regex::Regex;

/// Performs value substitution on a string value.
///
/// Supports `{{VAR_NAME}}` syntax. Escaping is supported via `\{\{` and `\}\}`.
/// Values are looked up via a [`ValueResolver`] — [`EnvValueResolver`](crate::EnvValueResolver)
/// by default, backed by `std::env::var`.
///
/// Used by real, technology-specific `LoaderOps` implementors (see
/// `configbuilder-svc-loader-spi`) to apply substitution after parsing a config file.
pub struct Substituter<'a> {
    policy: &'a dyn SubstitutionPolicy,
    resolver: &'a dyn ValueResolver,
    location: String,
}

impl<'a> Substituter<'a> {
    /// Create a new substituter with a policy and value resolver.
    pub fn new(
        policy: &'a dyn SubstitutionPolicy,
        resolver: &'a dyn ValueResolver,
        location: String,
    ) -> Self {
        Self {
            policy,
            resolver,
            location,
        }
    }

    /// Substitute all `{{VAR_NAME}}` placeholders in the given value.
    ///
    /// # Returns
    /// - `Ok(substituted_value)` if substitution succeeds
    /// - `Err(SubstitutionError)` if:
    ///   - A referenced variable doesn't exist
    ///   - A variable is rejected by the policy
    ///   - The syntax is invalid (e.g., nested placeholders)
    pub fn substitute(&self, value: &str) -> Result<String, SubstitutionError> {
        // Check for invalid nested syntax
        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(),
            });
        }

        // Replace escaped braces first (temporarily)
        let escaped = value
            .replace("\\{\\{", "\x00ESCAPED_OPEN\x00")
            .replace("\\}\\}", "\x00ESCAPED_CLOSE\x00");

        // Find and replace all {{VAR_NAME}} patterns
        #[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: &regex::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", "}}"))
    }

    /// Substitute a single variable via the configured resolver.
    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())
    }

    /// @covers: new
    #[test]
    fn test_new() {
        let policy = AllowAllPolicy;
        let sub = Substituter::new(&policy, &RESOLVER, "test_file.toml".to_string());
        assert!(!sub.location.is_empty());
    }

    /// @covers: substitute
    #[test]
    fn test_substitute() {
        let policy = AllowAllPolicy;
        let sub = substituter(&policy);
        let result = must(sub.substitute("value"));
        assert_eq!(result, "value");
    }

    /// @covers: substitute
    #[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");
    }

    /// @covers: substitute
    #[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");
    }

    /// @covers: substitute
    #[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");
    }

    /// @covers: substitute
    #[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");
    }

    /// @covers: substitute
    #[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 { .. })
        ));
    }

    /// @covers: substitute
    #[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 { .. })
        ));
    }

    /// @covers: substitute
    #[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 { .. })
        ));
    }
}