pipedash_core/infrastructure/config/
interpolation.rs1use std::sync::LazyLock;
2
3use regex::Regex;
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum InterpolationError {
8 #[error("Required environment variable not found: {0}")]
9 RequiredVarNotFound(String),
10
11 #[error("Invalid variable syntax: {0}")]
12 InvalidSyntax(String),
13
14 #[error("Recursive interpolation limit exceeded")]
15 RecursionLimit,
16}
17
18pub type InterpolationResult<T> = Result<T, InterpolationError>;
19
20const MAX_RECURSION_DEPTH: usize = 10;
21
22static VAR_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
23 Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").expect("Invalid regex pattern")
24});
25
26pub fn interpolate(input: &str) -> InterpolationResult<String> {
27 interpolate_with_depth(input, 0)
28}
29
30fn interpolate_with_depth(input: &str, depth: usize) -> InterpolationResult<String> {
31 if depth > MAX_RECURSION_DEPTH {
32 return Err(InterpolationError::RecursionLimit);
33 }
34
35 let mut result = input.to_string();
36 let mut changed = true;
37
38 while changed {
39 changed = false;
40
41 let matches: Vec<_> = VAR_PATTERN
42 .captures_iter(&result)
43 .map(|cap| {
44 let full_match = cap.get(0).unwrap();
45 let var_name = cap.get(1).unwrap().as_str().to_string();
46 let default = cap.get(2).map(|m| m.as_str().to_string());
47 (
48 full_match.start(),
49 full_match.end(),
50 full_match.as_str().to_string(),
51 var_name,
52 default,
53 )
54 })
55 .collect();
56
57 for (_, _, full_match, var_name, default) in matches.into_iter().rev() {
58 let replacement = match std::env::var(&var_name) {
59 Ok(value) => value,
60 Err(_) => match default {
61 Some(def) => interpolate_with_depth(&def, depth + 1)?,
62 None => {
63 return Err(InterpolationError::RequiredVarNotFound(var_name));
64 }
65 },
66 };
67
68 result = result.replace(&full_match, &replacement);
69 changed = true;
70 }
71 }
72
73 Ok(result)
74}
75
76pub fn interpolate_toml(value: &mut toml::Value) -> InterpolationResult<()> {
77 match value {
78 toml::Value::String(s) => {
79 *s = interpolate(s)?;
80 }
81 toml::Value::Array(arr) => {
82 for item in arr {
83 interpolate_toml(item)?;
84 }
85 }
86 toml::Value::Table(table) => {
87 for (_, v) in table.iter_mut() {
88 interpolate_toml(v)?;
89 }
90 }
91 _ => {}
92 }
93 Ok(())
94}
95
96pub fn has_variables(input: &str) -> bool {
97 VAR_PATTERN.is_match(input)
98}
99
100pub fn extract_variable_names(input: &str) -> Vec<String> {
101 VAR_PATTERN
102 .captures_iter(input)
103 .map(|cap| cap.get(1).unwrap().as_str().to_string())
104 .collect()
105}
106
107#[derive(Debug, Clone)]
108pub struct InterpolatedValue {
109 pub value: String,
110 pub original: Option<String>,
111 pub resolved_vars: Vec<String>,
112 pub defaulted_vars: Vec<String>,
113}
114
115impl InterpolatedValue {
116 pub fn was_interpolated(&self) -> bool {
117 self.original.is_some()
118 }
119}
120
121pub fn interpolate_with_tracking(input: &str) -> InterpolationResult<InterpolatedValue> {
122 let mut resolved_vars = Vec::new();
123 let mut defaulted_vars = Vec::new();
124
125 for cap in VAR_PATTERN.captures_iter(input) {
126 let var_name = cap.get(1).unwrap().as_str().to_string();
127 let has_default = cap.get(2).is_some();
128
129 if std::env::var(&var_name).is_ok() {
130 resolved_vars.push(var_name);
131 } else if has_default {
132 defaulted_vars.push(var_name);
133 }
134 }
135
136 let value = interpolate(input)?;
137 let original = if value != input {
138 Some(input.to_string())
139 } else {
140 None
141 };
142
143 Ok(InterpolatedValue {
144 value,
145 original,
146 resolved_vars,
147 defaulted_vars,
148 })
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_simple_var() {
157 std::env::set_var("TEST_VAR_SIMPLE", "hello");
158 let result = interpolate("${TEST_VAR_SIMPLE}").unwrap();
159 assert_eq!(result, "hello");
160 std::env::remove_var("TEST_VAR_SIMPLE");
161 }
162
163 #[test]
164 fn test_var_with_text() {
165 std::env::set_var("TEST_VAR_TEXT", "world");
166 let result = interpolate("Hello ${TEST_VAR_TEXT}!").unwrap();
167 assert_eq!(result, "Hello world!");
168 std::env::remove_var("TEST_VAR_TEXT");
169 }
170
171 #[test]
172 fn test_multiple_vars() {
173 std::env::set_var("TEST_VAR_A", "foo");
174 std::env::set_var("TEST_VAR_B", "bar");
175 let result = interpolate("${TEST_VAR_A}-${TEST_VAR_B}").unwrap();
176 assert_eq!(result, "foo-bar");
177 std::env::remove_var("TEST_VAR_A");
178 std::env::remove_var("TEST_VAR_B");
179 }
180
181 #[test]
182 fn test_missing_var_error() {
183 let result = interpolate("${THIS_VAR_DOES_NOT_EXIST_12345}");
184 assert!(matches!(
185 result,
186 Err(InterpolationError::RequiredVarNotFound(_))
187 ));
188 }
189
190 #[test]
191 fn test_default_value() {
192 let result = interpolate("${NONEXISTENT_VAR_123:-default_value}").unwrap();
193 assert_eq!(result, "default_value");
194 }
195
196 #[test]
197 fn test_empty_default() {
198 let result = interpolate("prefix${NONEXISTENT_VAR_456:-}suffix").unwrap();
199 assert_eq!(result, "prefixsuffix");
200 }
201
202 #[test]
203 fn test_var_overrides_default() {
204 std::env::set_var("TEST_VAR_OVERRIDE", "actual");
205 let result = interpolate("${TEST_VAR_OVERRIDE:-default}").unwrap();
206 assert_eq!(result, "actual");
207 std::env::remove_var("TEST_VAR_OVERRIDE");
208 }
209
210 #[test]
211 fn test_nested_default() {
212 std::env::set_var("TEST_NESTED_INNER", "inner_value");
213 let result = interpolate("${NONEXISTENT:-${TEST_NESTED_INNER}}").unwrap();
214 assert_eq!(result, "inner_value");
215 std::env::remove_var("TEST_NESTED_INNER");
216 }
217
218 #[test]
219 fn test_no_interpolation() {
220 let result = interpolate("plain text").unwrap();
221 assert_eq!(result, "plain text");
222 }
223
224 #[test]
225 fn test_has_variables() {
226 assert!(has_variables("${VAR}"));
227 assert!(has_variables("prefix${VAR}suffix"));
228 assert!(has_variables("${VAR:-default}"));
229 assert!(!has_variables("plain text"));
230 assert!(!has_variables("$VAR")); }
232
233 #[test]
234 fn test_extract_variable_names() {
235 let names = extract_variable_names("${FOO}-${BAR:-default}");
236 assert_eq!(names, vec!["FOO", "BAR"]);
237 }
238
239 #[test]
240 fn test_interpolate_with_tracking() {
241 std::env::set_var("TEST_TRACKED", "value");
242 let result = interpolate_with_tracking("${TEST_TRACKED} ${MISSING:-default}").unwrap();
243
244 assert!(result.was_interpolated());
245 assert_eq!(result.value, "value default");
246 assert!(result.resolved_vars.contains(&"TEST_TRACKED".to_string()));
247 assert!(result.defaulted_vars.contains(&"MISSING".to_string()));
248 std::env::remove_var("TEST_TRACKED");
249 }
250
251 #[test]
252 fn test_interpolate_toml() {
253 std::env::set_var("TEST_TOML_VAR", "toml_value");
254
255 let toml_str = r#"
256 key = "${TEST_TOML_VAR}"
257 nested = { inner = "${TEST_TOML_VAR:-fallback}" }
258 array = ["${TEST_TOML_VAR}", "static"]
259 "#;
260
261 let mut value: toml::Value = toml::from_str(toml_str).unwrap();
262 interpolate_toml(&mut value).unwrap();
263
264 assert_eq!(value["key"].as_str().unwrap(), "toml_value");
265 assert_eq!(value["nested"]["inner"].as_str().unwrap(), "toml_value");
266 assert_eq!(value["array"][0].as_str().unwrap(), "toml_value");
267 assert_eq!(value["array"][1].as_str().unwrap(), "static");
268
269 std::env::remove_var("TEST_TOML_VAR");
270 }
271
272 #[test]
273 fn test_complex_address() {
274 std::env::set_var("TEST_HOST", "localhost");
275 std::env::set_var("TEST_PORT", "9000");
276
277 let result = interpolate("${TEST_HOST:-127.0.0.1}:${TEST_PORT:-8080}").unwrap();
278 assert_eq!(result, "localhost:9000");
279
280 std::env::remove_var("TEST_HOST");
281 std::env::remove_var("TEST_PORT");
282
283 let result = interpolate("${TEST_HOST:-127.0.0.1}:${TEST_PORT:-8080}").unwrap();
284 assert_eq!(result, "127.0.0.1:8080");
285 }
286}