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