Skip to main content

kinetic_config/
interpolation.rs

1//! Environment variable interpolation for configuration files.
2//!
3//! Supports substituting `${ENV_VAR}` or `$ENV_VAR` in strings.
4
5use regex::Regex;
6use snafu::Snafu;
7use std::collections::HashMap;
8use std::env;
9use std::sync::OnceLock;
10
11#[derive(Debug, Snafu)]
12pub enum Error {
13    #[snafu(display("Environment variable not found: {}", var))]
14    EnvVarNotFound { var: String },
15}
16
17type Result<T, E = Error> = std::result::Result<T, E>;
18
19/// Returns a pre-compiled regex for matching `${VAR}` or `$VAR` patterns.
20fn get_interp_regex() -> &'static Regex {
21    static REGEX: OnceLock<Regex> = OnceLock::new();
22    REGEX.get_or_init(|| {
23        // Matches ${VAR} or ${VAR:-default} or $VAR.
24        // Captures the variable name in group 1 or 3.
25        // Captures the default value in group 2.
26        // SAFETY: This is a compile-time constant regex pattern that is known to be valid.
27        Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)")
28            .expect("interpolation regex is a valid constant pattern")
29    })
30}
31
32/// Interpolates a string by replacing environment variables.
33///
34/// If an environment variable is referenced but not set, returns `Error::EnvVarNotFound`.
35pub fn interpolate_string(s: &str) -> Result<String> {
36    // Avoid regex overhead if there are no dollar signs.
37    if !s.contains('$') {
38        return Ok(s.to_string());
39    }
40
41    let re = get_interp_regex();
42    let mut result = String::with_capacity(s.len());
43    let mut last_match = 0;
44
45    for caps in re.captures_iter(s) {
46        // SAFETY: Group 0 always exists for any successful regex capture.
47        let m = caps.get(0).expect("regex group 0 always exists");
48
49        // Append text before the match
50        result.push_str(&s[last_match..m.start()]);
51
52        // SAFETY: The regex guarantees that either group 1 (braced form) or group 3 (bare form) matches.
53        let var_name = caps
54            .get(1)
55            .or_else(|| caps.get(3))
56            .expect("regex guarantees group 1 or 3 matches")
57            .as_str();
58        let default_val = caps.get(2).map(|m| m.as_str());
59
60        // Look up the environment variable
61        let val = env::var(var_name).or_else(|_| {
62            default_val
63                .map(String::from)
64                .ok_or_else(|| Error::EnvVarNotFound {
65                    var: var_name.to_string(),
66                })
67        })?;
68
69        result.push_str(&val);
70        last_match = m.end();
71    }
72
73    // Append remaining text
74    result.push_str(&s[last_match..]);
75
76    Ok(result)
77}
78
79/// Helper struct for interpolating a raw configuration (e.g. JSON/YAML AST).
80///
81/// Currently operates on strings directly, but designed to be applied
82/// iteratively over parsed configuration trees if needed.
83pub struct Interpolator {
84    // We could add custom mock env vars here for testing if desired.
85    env_overrides: Option<HashMap<String, String>>,
86}
87
88impl Default for Interpolator {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Interpolator {
95    pub fn new() -> Self {
96        Self {
97            env_overrides: None,
98        }
99    }
100
101    #[cfg(test)]
102    pub fn with_overrides(overrides: HashMap<String, String>) -> Self {
103        Self {
104            env_overrides: Some(overrides),
105        }
106    }
107
108    /// Interpolate a string using the environment or mock overrides.
109    pub fn interpolate(&self, s: &str) -> Result<String> {
110        if !s.contains('$') {
111            return Ok(s.to_string());
112        }
113
114        let re = get_interp_regex();
115        let mut result = String::with_capacity(s.len());
116        let mut last_match = 0;
117
118        for caps in re.captures_iter(s) {
119            // SAFETY: Group 0 always exists for any successful regex capture.
120            let m = caps.get(0).expect("regex group 0 always exists");
121            result.push_str(&s[last_match..m.start()]);
122            // SAFETY: The regex guarantees that either group 1 (braced form) or group 3 (bare form) matches.
123            let var_name = caps
124                .get(1)
125                .or_else(|| caps.get(3))
126                .expect("regex guarantees group 1 or 3 matches")
127                .as_str();
128            let default_val = caps.get(2).map(|m| m.as_str());
129
130            let val = self.get_var(var_name).or_else(|_| {
131                default_val
132                    .map(String::from)
133                    .ok_or_else(|| Error::EnvVarNotFound {
134                        var: var_name.to_string(),
135                    })
136            })?;
137            result.push_str(&val);
138
139            last_match = m.end();
140        }
141
142        result.push_str(&s[last_match..]);
143        Ok(result)
144    }
145
146    fn get_var(&self, name: &str) -> Result<String> {
147        if let Some(overrides) = &self.env_overrides
148            && let Some(v) = overrides.get(name)
149        {
150            return Ok(v.clone());
151        }
152
153        env::var(name).map_err(|_| Error::EnvVarNotFound {
154            var: name.to_string(),
155        })
156    }
157}
158
159#[cfg(test)]
160#[allow(clippy::unwrap_used)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_interpolation_no_vars() {
166        let interp = Interpolator::new();
167        assert_eq!(interp.interpolate("hello world").unwrap(), "hello world");
168    }
169
170    #[test]
171    fn test_interpolation_with_vars() {
172        let mut envs = HashMap::new();
173        envs.insert("NAME".to_string(), "world".to_string());
174        envs.insert("PORT".to_string(), "8080".to_string());
175
176        let interp = Interpolator::with_overrides(envs);
177
178        assert_eq!(interp.interpolate("hello ${NAME}").unwrap(), "hello world");
179        assert_eq!(
180            interp.interpolate("listening on $PORT").unwrap(),
181            "listening on 8080"
182        );
183        assert_eq!(
184            interp.interpolate("http://${NAME}:$PORT/api").unwrap(),
185            "http://world:8080/api"
186        );
187    }
188
189    #[test]
190    fn test_interpolation_with_default_values() {
191        let interp = Interpolator::new();
192        assert_eq!(
193            interp.interpolate("hello ${UNDEFINED_VAR:-world}").unwrap(),
194            "hello world"
195        );
196        assert_eq!(
197            interp.interpolate("hello ${UNDEFINED_VAR:-}").unwrap(),
198            "hello "
199        );
200    }
201
202    #[test]
203    fn test_interpolation_missing_var() {
204        let interp = Interpolator::new();
205        let result = interp.interpolate("hello ${MISSING_VAR_XYZ}");
206        assert!(matches!(result, Err(Error::EnvVarNotFound { .. })));
207    }
208}