Skip to main content

modkit_utils/
var_expand.rs

1//! Single-pass expansion of `${VAR}` and `${VAR:-default}` placeholders from environment
2//! variables.
3
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8/// Error returned by [`expand_env_vars`].
9#[derive(Debug)]
10pub enum ExpandVarsError {
11    /// An environment variable referenced by the input is missing or contains invalid Unicode.
12    Var {
13        name: String,
14        source: std::env::VarError,
15    },
16    /// The internal regex failed to compile (should never happen with a literal pattern).
17    Regex(String),
18}
19
20impl std::fmt::Display for ExpandVarsError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::Var { name, source } => {
24                write!(f, "environment variable '{name}': {source}")
25            }
26            Self::Regex(msg) => write!(f, "env expansion regex error: {msg}"),
27        }
28    }
29}
30
31impl std::error::Error for ExpandVarsError {
32    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
33        match self {
34            Self::Var { source, .. } => Some(source),
35            Self::Regex(_) => None,
36        }
37    }
38}
39
40/// Expand `${VAR_NAME}` and `${VAR_NAME:-default}` placeholders in `input` with values
41/// from the environment.
42///
43/// - `${VAR}` — replaced with the value of `VAR`; **errors** if the variable is not set.
44/// - `${VAR:-value}` — replaced with the value of `VAR`, or `value` if the variable is
45///   not set. An empty default (`${VAR:-}`) is allowed and expands to the empty string.
46///   Default values must not contain `}` (nested `${…}` placeholders are not supported).
47/// - If a variable is set but empty, its (empty) value is always used regardless of any
48///   default.
49///
50/// Uses single-pass `Regex::replace_all` so that values themselves containing
51/// `${...}` are **not** re-expanded.  Fails on the first unresolvable variable
52/// that has no default.
53///
54/// # Errors
55///
56/// Returns [`ExpandVarsError::Var`] if a referenced environment variable is missing
57/// and no default value was provided.
58pub fn expand_env_vars(input: &str) -> Result<String, ExpandVarsError> {
59    static RE: LazyLock<Result<Regex, String>> = LazyLock::new(|| {
60        Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-((?:[^}])*))?\}").map_err(|e| e.to_string())
61    });
62    let re = RE.as_ref().map_err(|e| ExpandVarsError::Regex(e.clone()))?;
63
64    let mut err: Option<ExpandVarsError> = None;
65    let result = re.replace_all(input, |caps: &regex::Captures| {
66        if err.is_some() {
67            return String::new();
68        }
69        let name = &caps[1];
70        match std::env::var(name) {
71            Ok(val) => val,
72            Err(e) => {
73                if matches!(&e, std::env::VarError::NotPresent)
74                    && let Some(default) = caps.get(2)
75                {
76                    return default.as_str().to_owned();
77                }
78                err = Some(ExpandVarsError::Var {
79                    name: name.to_owned(),
80                    source: e,
81                });
82                String::new()
83            }
84        }
85    });
86    if let Some(e) = err {
87        return Err(e);
88    }
89    Ok(result.into_owned())
90}
91
92/// Trait for types whose `String` fields can be expanded from environment variables.
93///
94/// Typically derived via `#[derive(ExpandVars)]` from `modkit-macros`.
95/// Fields marked with `#[expand_vars]` will have `${VAR}` placeholders
96/// replaced with the corresponding environment variable values.
97///
98/// # Errors
99///
100/// Returns [`ExpandVarsError`] if a referenced environment variable is missing
101/// or contains invalid Unicode.
102pub trait ExpandVars {
103    /// Expand `${VAR}` placeholders in marked fields from environment variables.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`ExpandVarsError`] if a referenced environment variable is missing
108    /// or contains invalid Unicode.
109    fn expand_vars(&mut self) -> Result<(), ExpandVarsError>;
110}
111
112impl ExpandVars for String {
113    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
114        *self = expand_env_vars(self)?;
115        Ok(())
116    }
117}
118
119impl<T: ExpandVars> ExpandVars for Option<T> {
120    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
121        if let Some(inner) = self {
122            inner.expand_vars()?;
123        }
124        Ok(())
125    }
126}
127
128impl<T: ExpandVars> ExpandVars for Vec<T> {
129    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
130        for item in self {
131            item.expand_vars()?;
132        }
133        Ok(())
134    }
135}
136
137impl<K, V: ExpandVars, S: std::hash::BuildHasher> ExpandVars
138    for std::collections::HashMap<K, V, S>
139{
140    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
141        for val in self.values_mut() {
142            val.expand_vars()?;
143        }
144        Ok(())
145    }
146}
147
148impl ExpandVars for secrecy::SecretString {
149    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
150        use secrecy::ExposeSecret;
151        let expanded = expand_env_vars(self.expose_secret())?;
152        *self = secrecy::SecretString::from(expanded);
153        Ok(())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn passthrough_when_no_placeholders() {
163        let result = expand_env_vars("plain string without vars").unwrap();
164        assert_eq!(result, "plain string without vars");
165    }
166
167    #[test]
168    fn single_variable() {
169        temp_env::with_vars([("EXPAND_SINGLE", Some("replaced"))], || {
170            let result = expand_env_vars("prefix_${EXPAND_SINGLE}_suffix").unwrap();
171            assert_eq!(result, "prefix_replaced_suffix");
172        });
173    }
174
175    #[test]
176    fn multiple_variables() {
177        temp_env::with_vars(
178            [
179                ("EXPAND_HOST", Some("localhost")),
180                ("EXPAND_PORT", Some("5432")),
181            ],
182            || {
183                let result = expand_env_vars("${EXPAND_HOST}:${EXPAND_PORT}").unwrap();
184                assert_eq!(result, "localhost:5432");
185            },
186        );
187    }
188
189    #[test]
190    fn missing_var_returns_error_with_name() {
191        temp_env::with_vars([("EXPAND_MISSING_CANARY", None::<&str>)], || {
192            let err = expand_env_vars("${EXPAND_MISSING_CANARY}").unwrap_err();
193            assert!(
194                matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_MISSING_CANARY")
195            );
196            let msg = err.to_string();
197            assert!(
198                msg.contains("EXPAND_MISSING_CANARY"),
199                "error should contain var name, got: {msg}"
200            );
201        });
202    }
203
204    #[test]
205    fn fails_on_first_missing_variable() {
206        temp_env::with_vars(
207            [
208                ("EXPAND_FIRST_MISS", None::<&str>),
209                ("EXPAND_SECOND_OK", Some("present")),
210            ],
211            || {
212                let err = expand_env_vars("${EXPAND_FIRST_MISS}_${EXPAND_SECOND_OK}").unwrap_err();
213                assert!(
214                    matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_FIRST_MISS")
215                );
216            },
217        );
218    }
219
220    #[test]
221    fn default_value_used_when_var_missing() {
222        temp_env::with_vars([("EXPAND_DEF_MISS", None::<&str>)], || {
223            let result = expand_env_vars("${EXPAND_DEF_MISS:-8080}").unwrap();
224            assert_eq!(result, "8080");
225        });
226    }
227
228    #[test]
229    fn empty_default_expands_to_empty_string() {
230        temp_env::with_vars([("EXPAND_DEF_EMPTY", None::<&str>)], || {
231            let result = expand_env_vars("prefix_${EXPAND_DEF_EMPTY:-}_suffix").unwrap();
232            assert_eq!(result, "prefix__suffix");
233        });
234    }
235
236    #[test]
237    fn default_ignored_when_var_is_set() {
238        temp_env::with_vars([("EXPAND_DEF_SET", Some("actual"))], || {
239            let result = expand_env_vars("${EXPAND_DEF_SET:-fallback}").unwrap();
240            assert_eq!(result, "actual");
241        });
242    }
243
244    #[test]
245    fn empty_var_uses_empty_value_not_default() {
246        temp_env::with_vars([("EXPAND_DEF_EMPTYVAL", Some(""))], || {
247            let result = expand_env_vars("${EXPAND_DEF_EMPTYVAL:-fallback}").unwrap();
248            assert_eq!(result, "");
249        });
250    }
251
252    #[test]
253    fn no_default_still_errors_on_missing() {
254        temp_env::with_vars([("EXPAND_DEF_NODEF", None::<&str>)], || {
255            let err = expand_env_vars("${EXPAND_DEF_NODEF}").unwrap_err();
256            assert!(
257                matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_DEF_NODEF")
258            );
259        });
260    }
261
262    #[test]
263    fn multiple_defaults_in_one_string() {
264        temp_env::with_vars(
265            [
266                ("EXPAND_MULTI_A", None::<&str>),
267                ("EXPAND_MULTI_B", Some("set")),
268            ],
269            || {
270                let result =
271                    expand_env_vars("${EXPAND_MULTI_A:-alpha}_${EXPAND_MULTI_B:-beta}").unwrap();
272                assert_eq!(result, "alpha_set");
273            },
274        );
275    }
276
277    /// Regression: values containing `${...}` must not be re-expanded.
278    /// Input `${A}_${B}` with A=`${B}` and B=`val` must yield `${B}_val`, not `val_val`.
279    #[test]
280    fn no_double_expansion() {
281        temp_env::with_vars(
282            [
283                ("EXPAND_TEST_A", Some("${EXPAND_TEST_B}")),
284                ("EXPAND_TEST_B", Some("val")),
285            ],
286            || {
287                let result = expand_env_vars("${EXPAND_TEST_A}_${EXPAND_TEST_B}").unwrap();
288                assert_eq!(result, "${EXPAND_TEST_B}_val");
289            },
290        );
291    }
292}