Skip to main content

modkit_utils/
var_expand.rs

1//! Single-pass expansion of `${VAR}` placeholders from environment variables.
2
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7/// Error returned by [`expand_env_vars`].
8#[derive(Debug)]
9pub enum ExpandVarsError {
10    /// An environment variable referenced by the input is missing or contains invalid Unicode.
11    Var {
12        name: String,
13        source: std::env::VarError,
14    },
15    /// The internal regex failed to compile (should never happen with a literal pattern).
16    Regex(String),
17}
18
19impl std::fmt::Display for ExpandVarsError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::Var { name, source } => {
23                write!(f, "environment variable '{name}': {source}")
24            }
25            Self::Regex(msg) => write!(f, "env expansion regex error: {msg}"),
26        }
27    }
28}
29
30impl std::error::Error for ExpandVarsError {
31    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
32        match self {
33            Self::Var { source, .. } => Some(source),
34            Self::Regex(_) => None,
35        }
36    }
37}
38
39/// Expand `${VAR_NAME}` placeholders in `input` with values from the environment.
40///
41/// Uses single-pass `Regex::replace_all` so that values themselves containing
42/// `${...}` are **not** re-expanded.  Fails on the first unresolvable variable.
43///
44/// # Errors
45///
46/// Returns [`ExpandVarsError::Var`] if a referenced environment variable is missing
47/// or contains invalid Unicode.
48pub fn expand_env_vars(input: &str) -> Result<String, ExpandVarsError> {
49    static RE: LazyLock<Result<Regex, String>> =
50        LazyLock::new(|| Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").map_err(|e| e.to_string()));
51    let re = RE.as_ref().map_err(|e| ExpandVarsError::Regex(e.clone()))?;
52
53    let mut err: Option<ExpandVarsError> = None;
54    let result = re.replace_all(input, |caps: &regex::Captures| {
55        if err.is_some() {
56            return String::new();
57        }
58        let name = &caps[1];
59        match std::env::var(name) {
60            Ok(val) => val,
61            Err(e) => {
62                err = Some(ExpandVarsError::Var {
63                    name: name.to_owned(),
64                    source: e,
65                });
66                String::new()
67            }
68        }
69    });
70    if let Some(e) = err {
71        return Err(e);
72    }
73    Ok(result.into_owned())
74}
75
76/// Trait for types whose `String` fields can be expanded from environment variables.
77///
78/// Typically derived via `#[derive(ExpandVars)]` from `modkit-macros`.
79/// Fields marked with `#[expand_vars]` will have `${VAR}` placeholders
80/// replaced with the corresponding environment variable values.
81///
82/// # Errors
83///
84/// Returns [`ExpandVarsError`] if a referenced environment variable is missing
85/// or contains invalid Unicode.
86pub trait ExpandVars {
87    /// Expand `${VAR}` placeholders in marked fields from environment variables.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`ExpandVarsError`] if a referenced environment variable is missing
92    /// or contains invalid Unicode.
93    fn expand_vars(&mut self) -> Result<(), ExpandVarsError>;
94}
95
96impl ExpandVars for String {
97    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
98        *self = expand_env_vars(self)?;
99        Ok(())
100    }
101}
102
103impl<T: ExpandVars> ExpandVars for Option<T> {
104    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
105        if let Some(inner) = self {
106            inner.expand_vars()?;
107        }
108        Ok(())
109    }
110}
111
112impl<T: ExpandVars> ExpandVars for Vec<T> {
113    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
114        for item in self {
115            item.expand_vars()?;
116        }
117        Ok(())
118    }
119}
120
121impl<K, V: ExpandVars, S: std::hash::BuildHasher> ExpandVars
122    for std::collections::HashMap<K, V, S>
123{
124    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
125        for val in self.values_mut() {
126            val.expand_vars()?;
127        }
128        Ok(())
129    }
130}
131
132impl ExpandVars for secrecy::SecretString {
133    fn expand_vars(&mut self) -> Result<(), ExpandVarsError> {
134        use secrecy::ExposeSecret;
135        let expanded = expand_env_vars(self.expose_secret())?;
136        *self = secrecy::SecretString::from(expanded);
137        Ok(())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn passthrough_when_no_placeholders() {
147        let result = expand_env_vars("plain string without vars").unwrap();
148        assert_eq!(result, "plain string without vars");
149    }
150
151    #[test]
152    fn single_variable() {
153        temp_env::with_vars([("EXPAND_SINGLE", Some("replaced"))], || {
154            let result = expand_env_vars("prefix_${EXPAND_SINGLE}_suffix").unwrap();
155            assert_eq!(result, "prefix_replaced_suffix");
156        });
157    }
158
159    #[test]
160    fn multiple_variables() {
161        temp_env::with_vars(
162            [
163                ("EXPAND_HOST", Some("localhost")),
164                ("EXPAND_PORT", Some("5432")),
165            ],
166            || {
167                let result = expand_env_vars("${EXPAND_HOST}:${EXPAND_PORT}").unwrap();
168                assert_eq!(result, "localhost:5432");
169            },
170        );
171    }
172
173    #[test]
174    fn missing_var_returns_error_with_name() {
175        temp_env::with_vars([("EXPAND_MISSING_CANARY", None::<&str>)], || {
176            let err = expand_env_vars("${EXPAND_MISSING_CANARY}").unwrap_err();
177            assert!(
178                matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_MISSING_CANARY")
179            );
180            let msg = err.to_string();
181            assert!(
182                msg.contains("EXPAND_MISSING_CANARY"),
183                "error should contain var name, got: {msg}"
184            );
185        });
186    }
187
188    #[test]
189    fn fails_on_first_missing_variable() {
190        temp_env::with_vars(
191            [
192                ("EXPAND_FIRST_MISS", None::<&str>),
193                ("EXPAND_SECOND_OK", Some("present")),
194            ],
195            || {
196                let err = expand_env_vars("${EXPAND_FIRST_MISS}_${EXPAND_SECOND_OK}").unwrap_err();
197                assert!(
198                    matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_FIRST_MISS")
199                );
200            },
201        );
202    }
203
204    /// Regression: values containing `${...}` must not be re-expanded.
205    /// Input `${A}_${B}` with A=`${B}` and B=`val` must yield `${B}_val`, not `val_val`.
206    #[test]
207    fn no_double_expansion() {
208        temp_env::with_vars(
209            [
210                ("EXPAND_TEST_A", Some("${EXPAND_TEST_B}")),
211                ("EXPAND_TEST_B", Some("val")),
212            ],
213            || {
214                let result = expand_env_vars("${EXPAND_TEST_A}_${EXPAND_TEST_B}").unwrap();
215                assert_eq!(result, "${EXPAND_TEST_B}_val");
216            },
217        );
218    }
219}