Skip to main content

pipedash_core/infrastructure/config/
interpolation.rs

1use 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")); // Missing braces
231    }
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}