modkit_utils/
var_expand.rs1use std::sync::LazyLock;
5
6use regex::Regex;
7
8#[derive(Debug)]
10pub enum ExpandVarsError {
11 Var {
13 name: String,
14 source: std::env::VarError,
15 },
16 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
40pub 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: ®ex::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
92pub trait ExpandVars {
103 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 #[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}