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
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 #[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}