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
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn passthrough_when_no_placeholders() {
138        let result = expand_env_vars("plain string without vars").unwrap();
139        assert_eq!(result, "plain string without vars");
140    }
141
142    #[test]
143    fn single_variable() {
144        temp_env::with_vars([("EXPAND_SINGLE", Some("replaced"))], || {
145            let result = expand_env_vars("prefix_${EXPAND_SINGLE}_suffix").unwrap();
146            assert_eq!(result, "prefix_replaced_suffix");
147        });
148    }
149
150    #[test]
151    fn multiple_variables() {
152        temp_env::with_vars(
153            [
154                ("EXPAND_HOST", Some("localhost")),
155                ("EXPAND_PORT", Some("5432")),
156            ],
157            || {
158                let result = expand_env_vars("${EXPAND_HOST}:${EXPAND_PORT}").unwrap();
159                assert_eq!(result, "localhost:5432");
160            },
161        );
162    }
163
164    #[test]
165    fn missing_var_returns_error_with_name() {
166        temp_env::with_vars([("EXPAND_MISSING_CANARY", None::<&str>)], || {
167            let err = expand_env_vars("${EXPAND_MISSING_CANARY}").unwrap_err();
168            assert!(
169                matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_MISSING_CANARY")
170            );
171            let msg = err.to_string();
172            assert!(
173                msg.contains("EXPAND_MISSING_CANARY"),
174                "error should contain var name, got: {msg}"
175            );
176        });
177    }
178
179    #[test]
180    fn fails_on_first_missing_variable() {
181        temp_env::with_vars(
182            [
183                ("EXPAND_FIRST_MISS", None::<&str>),
184                ("EXPAND_SECOND_OK", Some("present")),
185            ],
186            || {
187                let err = expand_env_vars("${EXPAND_FIRST_MISS}_${EXPAND_SECOND_OK}").unwrap_err();
188                assert!(
189                    matches!(&err, ExpandVarsError::Var { name, .. } if name == "EXPAND_FIRST_MISS")
190                );
191            },
192        );
193    }
194
195    /// Regression: values containing `${...}` must not be re-expanded.
196    /// Input `${A}_${B}` with A=`${B}` and B=`val` must yield `${B}_val`, not `val_val`.
197    #[test]
198    fn no_double_expansion() {
199        temp_env::with_vars(
200            [
201                ("EXPAND_TEST_A", Some("${EXPAND_TEST_B}")),
202                ("EXPAND_TEST_B", Some("val")),
203            ],
204            || {
205                let result = expand_env_vars("${EXPAND_TEST_A}_${EXPAND_TEST_B}").unwrap();
206                assert_eq!(result, "${EXPAND_TEST_B}_val");
207            },
208        );
209    }
210}