Skip to main content

configbuilder_pattern_substitution/
substituter.rs

1use crate::{PolicyCatalog, SubstitutionError, SubstitutionPolicy, ValueResolver};
2use regex::Regex;
3
4/// Performs value substitution on a string value.
5///
6/// Supports `{{VAR_NAME}}` syntax. Escaping is supported via `\{\{` and `\}\}`.
7/// Values are looked up via a [`ValueResolver`] — [`EnvValueResolver`](crate::EnvValueResolver)
8/// by default, backed by `std::env::var`.
9///
10/// Used by real, technology-specific `LoaderOps` implementors (see
11/// `configbuilder-svc-loader-spi`) to apply substitution after parsing a config file.
12pub struct Substituter<'a> {
13    policy: &'a dyn SubstitutionPolicy,
14    resolver: &'a dyn ValueResolver,
15    location: String,
16}
17
18impl<'a> Substituter<'a> {
19    /// Create a new substituter with a policy and value resolver.
20    pub fn new(
21        policy: &'a dyn SubstitutionPolicy,
22        resolver: &'a dyn ValueResolver,
23        location: String,
24    ) -> Self {
25        Self {
26            policy,
27            resolver,
28            location,
29        }
30    }
31
32    /// Substitute all `{{VAR_NAME}}` placeholders in the given value.
33    ///
34    /// # Returns
35    /// - `Ok(substituted_value)` if substitution succeeds
36    /// - `Err(SubstitutionError)` if:
37    ///   - A referenced variable doesn't exist
38    ///   - A variable is rejected by the policy
39    ///   - The syntax is invalid (e.g., nested placeholders)
40    pub fn substitute(&self, value: &str) -> Result<String, SubstitutionError> {
41        // Check for invalid nested syntax
42        if value.contains("{{{{") || value.contains("}}}}") {
43            return Err(SubstitutionError::InvalidSubstitutionSyntax {
44                value: value.to_string(),
45                reason: "nested placeholders (e.g., {{VAR_{{OTHER}}}}) are not supported"
46                    .to_string(),
47            });
48        }
49
50        // Replace escaped braces first (temporarily)
51        let escaped = value
52            .replace("\\{\\{", "\x00ESCAPED_OPEN\x00")
53            .replace("\\}\\}", "\x00ESCAPED_CLOSE\x00");
54
55        // Find and replace all {{VAR_NAME}} patterns
56        #[allow(clippy::expect_used)]
57        let re = Regex::new(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}")
58            .expect("valid regex pattern for {{VAR_NAME}} substitution");
59        let mut first_error: Option<SubstitutionError> = None;
60        let result = re
61            .replace_all(&escaped, |caps: &regex::Captures| {
62                let var_name = &caps[1];
63                match self.substitute_var(var_name) {
64                    Ok(value) => value,
65                    Err(e) => {
66                        if first_error.is_none() {
67                            first_error = Some(e);
68                        }
69                        String::new()
70                    }
71                }
72            })
73            .to_string();
74
75        if let Some(err) = first_error {
76            return Err(err);
77        }
78
79        Ok(result
80            .replace("\x00ESCAPED_OPEN\x00", "{{")
81            .replace("\x00ESCAPED_CLOSE\x00", "}}"))
82    }
83
84    /// Substitute a single variable via the configured resolver.
85    fn substitute_var(&self, var_name: &str) -> Result<String, SubstitutionError> {
86        self.policy.validate(var_name)?;
87        self.resolver.resolve(var_name, &self.location)
88    }
89}
90
91impl<'a> PolicyCatalog for Substituter<'a> {
92    type SubstitutionError = crate::SubstitutionError;
93    type CompositePolicy = crate::CompositePolicy;
94    type PatternWhitelistPolicy = crate::PatternWhitelistPolicy;
95    type PrefixWhitelistPolicy = crate::PrefixWhitelistPolicy;
96
97    #[cfg(any(test, feature = "test-utils"))]
98    type AllowAllPolicy = crate::AllowAllPolicy;
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::{AllowAllPolicy, EnvValueResolver};
105
106    static RESOLVER: EnvValueResolver = EnvValueResolver;
107
108    fn must<T, E>(result: Result<T, E>) -> T {
109        result.unwrap_or_else(|_| std::process::abort())
110    }
111
112    fn substituter(policy: &dyn SubstitutionPolicy) -> Substituter<'_> {
113        Substituter::new(policy, &RESOLVER, "test_location".to_string())
114    }
115
116    /// @covers: new
117    #[test]
118    fn test_new() {
119        let policy = AllowAllPolicy;
120        let sub = Substituter::new(&policy, &RESOLVER, "test_file.toml".to_string());
121        assert!(!sub.location.is_empty());
122    }
123
124    /// @covers: substitute
125    #[test]
126    fn test_substitute() {
127        let policy = AllowAllPolicy;
128        let sub = substituter(&policy);
129        let result = must(sub.substitute("value"));
130        assert_eq!(result, "value");
131    }
132
133    /// @covers: substitute
134    #[test]
135    fn test_no_substitution_needed() {
136        let policy = AllowAllPolicy;
137        let sub = substituter(&policy);
138        let result = must(sub.substitute("hello world"));
139        assert_eq!(result, "hello world");
140    }
141
142    /// @covers: substitute
143    #[test]
144    fn test_single_substitution() {
145        std::env::set_var("TEST_VAR", "value123");
146        let policy = AllowAllPolicy;
147        let sub = substituter(&policy);
148        let result = must(sub.substitute("prefix-{{TEST_VAR}}-suffix"));
149        assert_eq!(result, "prefix-value123-suffix");
150    }
151
152    /// @covers: substitute
153    #[test]
154    fn test_multiple_substitutions() {
155        std::env::set_var("VAR1", "hello");
156        std::env::set_var("VAR2", "world");
157        let policy = AllowAllPolicy;
158        let sub = substituter(&policy);
159        let result = must(sub.substitute("{{VAR1}} {{VAR2}}"));
160        assert_eq!(result, "hello world");
161    }
162
163    /// @covers: substitute
164    #[test]
165    fn test_escaped_braces() {
166        let policy = AllowAllPolicy;
167        let sub = substituter(&policy);
168        let result = must(sub.substitute(r"use \{\{VAR\}\} for substitution"));
169        assert_eq!(result, "use {{VAR}} for substitution");
170    }
171
172    /// @covers: substitute
173    #[test]
174    fn test_missing_variable() {
175        let policy = AllowAllPolicy;
176        let sub = substituter(&policy);
177        let result = sub.substitute("value: {{NONEXISTENT_VAR}}");
178        assert!(matches!(
179            result,
180            Err(SubstitutionError::VariableNotFound { .. })
181        ));
182    }
183
184    /// @covers: substitute
185    #[test]
186    fn test_nested_placeholder_rejected() {
187        let policy = AllowAllPolicy;
188        let sub = substituter(&policy);
189        let result = sub.substitute("{{VAR_{{INNER}}}}");
190        assert!(matches!(
191            result,
192            Err(SubstitutionError::InvalidSubstitutionSyntax { .. })
193        ));
194    }
195
196    /// @covers: substitute
197    #[test]
198    fn test_policy_rejection() {
199        use crate::PrefixWhitelistPolicy;
200        let policy = PrefixWhitelistPolicy::new(vec!["ALLOWED_".to_string()]);
201        let sub = substituter(&policy);
202        let result = sub.substitute("{{FORBIDDEN_VAR}}");
203        assert!(matches!(
204            result,
205            Err(SubstitutionError::VariableRejected { .. })
206        ));
207    }
208}