configbuilder_pattern_substitution/
substituter.rs1use crate::{PolicyCatalog, SubstitutionError, SubstitutionPolicy, ValueResolver};
2use regex::Regex;
3
4pub struct Substituter<'a> {
13 policy: &'a dyn SubstitutionPolicy,
14 resolver: &'a dyn ValueResolver,
15 location: String,
16}
17
18impl<'a> Substituter<'a> {
19 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 pub fn substitute(&self, value: &str) -> Result<String, SubstitutionError> {
41 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 let escaped = value
52 .replace("\\{\\{", "\x00ESCAPED_OPEN\x00")
53 .replace("\\}\\}", "\x00ESCAPED_CLOSE\x00");
54
55 #[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: ®ex::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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}