kinetic_config/
interpolation.rs1use regex::Regex;
6use snafu::Snafu;
7use std::collections::HashMap;
8use std::env;
9use std::sync::OnceLock;
10
11#[derive(Debug, Snafu)]
12pub enum Error {
13 #[snafu(display("Environment variable not found: {}", var))]
14 EnvVarNotFound { var: String },
15}
16
17type Result<T, E = Error> = std::result::Result<T, E>;
18
19fn get_interp_regex() -> &'static Regex {
21 static REGEX: OnceLock<Regex> = OnceLock::new();
22 REGEX.get_or_init(|| {
23 Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)")
28 .expect("interpolation regex is a valid constant pattern")
29 })
30}
31
32pub fn interpolate_string(s: &str) -> Result<String> {
36 if !s.contains('$') {
38 return Ok(s.to_string());
39 }
40
41 let re = get_interp_regex();
42 let mut result = String::with_capacity(s.len());
43 let mut last_match = 0;
44
45 for caps in re.captures_iter(s) {
46 let m = caps.get(0).expect("regex group 0 always exists");
48
49 result.push_str(&s[last_match..m.start()]);
51
52 let var_name = caps
54 .get(1)
55 .or_else(|| caps.get(3))
56 .expect("regex guarantees group 1 or 3 matches")
57 .as_str();
58 let default_val = caps.get(2).map(|m| m.as_str());
59
60 let val = env::var(var_name).or_else(|_| {
62 default_val
63 .map(String::from)
64 .ok_or_else(|| Error::EnvVarNotFound {
65 var: var_name.to_string(),
66 })
67 })?;
68
69 result.push_str(&val);
70 last_match = m.end();
71 }
72
73 result.push_str(&s[last_match..]);
75
76 Ok(result)
77}
78
79pub struct Interpolator {
84 env_overrides: Option<HashMap<String, String>>,
86}
87
88impl Default for Interpolator {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl Interpolator {
95 pub fn new() -> Self {
96 Self {
97 env_overrides: None,
98 }
99 }
100
101 #[cfg(test)]
102 pub fn with_overrides(overrides: HashMap<String, String>) -> Self {
103 Self {
104 env_overrides: Some(overrides),
105 }
106 }
107
108 pub fn interpolate(&self, s: &str) -> Result<String> {
110 if !s.contains('$') {
111 return Ok(s.to_string());
112 }
113
114 let re = get_interp_regex();
115 let mut result = String::with_capacity(s.len());
116 let mut last_match = 0;
117
118 for caps in re.captures_iter(s) {
119 let m = caps.get(0).expect("regex group 0 always exists");
121 result.push_str(&s[last_match..m.start()]);
122 let var_name = caps
124 .get(1)
125 .or_else(|| caps.get(3))
126 .expect("regex guarantees group 1 or 3 matches")
127 .as_str();
128 let default_val = caps.get(2).map(|m| m.as_str());
129
130 let val = self.get_var(var_name).or_else(|_| {
131 default_val
132 .map(String::from)
133 .ok_or_else(|| Error::EnvVarNotFound {
134 var: var_name.to_string(),
135 })
136 })?;
137 result.push_str(&val);
138
139 last_match = m.end();
140 }
141
142 result.push_str(&s[last_match..]);
143 Ok(result)
144 }
145
146 fn get_var(&self, name: &str) -> Result<String> {
147 if let Some(overrides) = &self.env_overrides
148 && let Some(v) = overrides.get(name)
149 {
150 return Ok(v.clone());
151 }
152
153 env::var(name).map_err(|_| Error::EnvVarNotFound {
154 var: name.to_string(),
155 })
156 }
157}
158
159#[cfg(test)]
160#[allow(clippy::unwrap_used)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn test_interpolation_no_vars() {
166 let interp = Interpolator::new();
167 assert_eq!(interp.interpolate("hello world").unwrap(), "hello world");
168 }
169
170 #[test]
171 fn test_interpolation_with_vars() {
172 let mut envs = HashMap::new();
173 envs.insert("NAME".to_string(), "world".to_string());
174 envs.insert("PORT".to_string(), "8080".to_string());
175
176 let interp = Interpolator::with_overrides(envs);
177
178 assert_eq!(interp.interpolate("hello ${NAME}").unwrap(), "hello world");
179 assert_eq!(
180 interp.interpolate("listening on $PORT").unwrap(),
181 "listening on 8080"
182 );
183 assert_eq!(
184 interp.interpolate("http://${NAME}:$PORT/api").unwrap(),
185 "http://world:8080/api"
186 );
187 }
188
189 #[test]
190 fn test_interpolation_with_default_values() {
191 let interp = Interpolator::new();
192 assert_eq!(
193 interp.interpolate("hello ${UNDEFINED_VAR:-world}").unwrap(),
194 "hello world"
195 );
196 assert_eq!(
197 interp.interpolate("hello ${UNDEFINED_VAR:-}").unwrap(),
198 "hello "
199 );
200 }
201
202 #[test]
203 fn test_interpolation_missing_var() {
204 let interp = Interpolator::new();
205 let result = interp.interpolate("hello ${MISSING_VAR_XYZ}");
206 assert!(matches!(result, Err(Error::EnvVarNotFound { .. })));
207 }
208}