1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::types::{ResolvedVars, VarSource, VarTrace};
5
6pub fn resolve_variables(
14 runtime_vars: &HashMap<String, String>,
15 suite_vars: &HashMap<String, String>,
16 env_file: Option<&Path>,
17) -> ResolvedVars {
18 let dotenv_vars = load_dotenv(env_file);
20
21 let mut name_set = std::collections::BTreeSet::new();
23 name_set.extend(runtime_vars.keys().cloned());
24 name_set.extend(suite_vars.keys().cloned());
25 name_set.extend(dotenv_vars.keys().cloned());
26 let all_names: Vec<String> = name_set.into_iter().collect();
28
29 let mut vars = HashMap::new();
30 let mut traces = Vec::new();
31
32 for name in &all_names {
33 let mut found_in: Vec<(VarSource, String)> = Vec::new();
34
35 if let Ok(val) = std::env::var(name) {
37 found_in.push((VarSource::OsEnv, val));
38 }
39 if let Some(val) = dotenv_vars.get(name) {
40 found_in.push((VarSource::DotEnv, val.clone()));
41 }
42 if let Some(val) = suite_vars.get(name) {
43 found_in.push((VarSource::SuiteFile, val.clone()));
44 }
45 if let Some(val) = runtime_vars.get(name) {
46 found_in.push((VarSource::Runtime, val.clone()));
47 }
48
49 if let Some((source, value)) = found_in.last() {
50 vars.insert(name.clone(), value.clone());
51 traces.push(VarTrace {
52 name: name.clone(),
53 value: value.clone(),
54 source: source.clone(),
55 found_in: found_in.clone(),
56 });
57 }
58 }
59
60 ResolvedVars { vars, traces }
61}
62
63pub struct SubstituteResult {
65 pub text: String,
67 pub unresolved: Vec<String>,
69}
70
71pub fn substitute_with_check(input: &str, vars: &HashMap<String, String>) -> SubstituteResult {
73 const MAX_DEPTH: usize = 10;
74 let mut result = input.to_string();
75 for _ in 0..MAX_DEPTH {
76 let next = substitute_inner(&result, vars, None);
77 if next == result {
78 break;
79 }
80 result = next;
81 }
82 let mut unresolved = Vec::new();
84 let _ = substitute_inner(&result, vars, Some(&mut unresolved));
85 unresolved.sort();
86 unresolved.dedup();
87 SubstituteResult {
88 text: result,
89 unresolved,
90 }
91}
92
93pub fn substitute(input: &str, vars: &HashMap<String, String>) -> String {
101 const MAX_DEPTH: usize = 10;
102 let mut result = input.to_string();
103 for _ in 0..MAX_DEPTH {
104 let next = substitute_inner(&result, vars, None);
105 if next == result {
106 break;
107 }
108 result = next;
109 }
110 result
111}
112
113fn substitute_inner(
114 input: &str,
115 vars: &HashMap<String, String>,
116 mut unresolved_out: Option<&mut Vec<String>>,
117) -> String {
118 let mut result = String::with_capacity(input.len());
119 let chars: Vec<char> = input.chars().collect();
120 let mut i = 0;
121
122 while i < chars.len() {
123 if i + 1 < chars.len()
125 && chars[i] == '{'
126 && chars[i + 1] == '{'
127 && let Some(end) = find_closing_braces(&chars, i + 2)
128 {
129 let var_name: String = chars[i + 2..end].iter().collect();
130 let var_name = var_name.trim();
131 if let Some(val) = vars.get(var_name) {
132 result.push_str(val);
133 } else {
134 let original: String = chars[i..end + 2].iter().collect();
136 result.push_str(&original);
137 if let Some(ref mut out) = unresolved_out {
138 out.push(var_name.to_string());
139 }
140 }
141 i = end + 2;
142 continue;
143 }
144
145 if i + 1 < chars.len()
147 && chars[i] == '$'
148 && chars[i + 1] == '{'
149 && let Some(end) = find_closing_brace(&chars, i + 2)
150 {
151 let inner: String = chars[i + 2..end].iter().collect();
152 let inner = inner.trim();
153
154 if let Some((var_name, default_val)) = inner.split_once(':') {
156 let var_name = var_name.trim();
157 let default_val = default_val.trim();
158 if let Some(val) = vars.get(var_name) {
159 result.push_str(val);
160 } else {
161 result.push_str(default_val);
162 }
163 } else if let Some(val) = vars.get(inner) {
164 result.push_str(val);
165 } else {
166 let original: String = chars[i..end + 1].iter().collect();
167 result.push_str(&original);
168 if let Some(ref mut out) = unresolved_out {
169 out.push(inner.to_string());
170 }
171 }
172 i = end + 1;
173 continue;
174 }
175
176 if chars[i] == '$'
178 && i + 1 < chars.len()
179 && (chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_')
180 {
181 let start = i + 1;
182 let mut end = start;
183 while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
184 end += 1;
185 }
186 let var_name: String = chars[start..end].iter().collect();
187
188 if let Some(val) = vars.get(&var_name) {
189 result.push_str(val);
190 } else {
191 result.push('$');
192 result.push_str(&var_name);
193 if let Some(ref mut out) = unresolved_out {
194 out.push(var_name.clone());
195 }
196 }
197 i = end;
198 continue;
199 }
200
201 result.push(chars[i]);
202 i += 1;
203 }
204
205 result
206}
207
208fn load_dotenv(path: Option<&Path>) -> HashMap<String, String> {
210 let mut map = HashMap::new();
211 if let Some(path) = path
212 && path.exists()
213 && let Ok(iter) = dotenvy::from_path_iter(path)
214 {
215 for (key, val) in iter.flatten() {
216 map.insert(key, val);
217 }
218 }
219 map
220}
221
222fn find_closing_braces(chars: &[char], start: usize) -> Option<usize> {
223 let mut i = start;
224 while i + 1 < chars.len() {
225 if chars[i] == '}' && chars[i + 1] == '}' {
226 return Some(i);
227 }
228 i += 1;
229 }
230 None
231}
232
233fn find_closing_brace(chars: &[char], start: usize) -> Option<usize> {
234 let mut i = start;
235 while i < chars.len() {
236 if chars[i] == '}' {
237 return Some(i);
238 }
239 i += 1;
240 }
241 None
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn make_vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
249 pairs
250 .iter()
251 .map(|(k, v)| (k.to_string(), v.to_string()))
252 .collect()
253 }
254
255 #[test]
256 fn substitute_double_braces() {
257 let vars = make_vars(&[("BASE_URL", "https://example.com")]);
258 assert_eq!(
259 substitute("{{BASE_URL}}/api", &vars),
260 "https://example.com/api"
261 );
262 }
263
264 #[test]
265 fn substitute_dollar_brace() {
266 let vars = make_vars(&[("API_KEY", "secret")]);
267 assert_eq!(substitute("Bearer ${API_KEY}", &vars), "Bearer secret");
268 }
269
270 #[test]
271 fn substitute_dollar_bare() {
272 let vars = make_vars(&[("HOST", "localhost")]);
273 assert_eq!(
274 substitute("http://$HOST:8080", &vars),
275 "http://localhost:8080"
276 );
277 }
278
279 #[test]
280 fn substitute_default_value_brace() {
281 let vars: HashMap<String, String> = HashMap::new();
282 assert_eq!(substitute("${MISSING:fallback}", &vars), "fallback");
283 }
284
285 #[test]
286 fn substitute_default_not_used_when_present_brace() {
287 let vars = make_vars(&[("PORT", "9090")]);
288 assert_eq!(substitute("${PORT:8080}", &vars), "9090");
289 }
290
291 #[test]
292 fn substitute_bare_dollar_no_default() {
293 let vars = make_vars(&[("PORT", "9090")]);
295 assert_eq!(substitute("$PORT:8080", &vars), "9090:8080");
296 }
297
298 #[test]
299 fn substitute_unresolved_preserved() {
300 let vars: HashMap<String, String> = HashMap::new();
301 assert_eq!(substitute("{{UNKNOWN}}", &vars), "{{UNKNOWN}}");
302 }
303
304 #[test]
305 fn resolve_priority() {
306 let runtime = make_vars(&[("KEY", "from_runtime")]);
308 let suite = make_vars(&[("KEY", "from_suite"), ("OTHER", "suite_val")]);
309
310 let resolved = resolve_variables(&runtime, &suite, None);
311 assert_eq!(resolved.vars.get("KEY").unwrap(), "from_runtime");
312 assert_eq!(resolved.vars.get("OTHER").unwrap(), "suite_val");
313
314 let key_trace = resolved.traces.iter().find(|t| t.name == "KEY").unwrap();
316 assert_eq!(key_trace.source, VarSource::Runtime);
317 assert!(key_trace.found_in.len() >= 2);
318 }
319
320 #[test]
321 fn substitute_with_check_detects_unresolved_double_braces() {
322 let vars = make_vars(&[("FOO", "abc")]);
323 let result = substitute_with_check("{{FOO}}/{{BAR}}/{{BAZ}}", &vars);
324 assert_eq!(result.text, "abc/{{BAR}}/{{BAZ}}");
325 assert_eq!(result.unresolved, vec!["BAR", "BAZ"]);
326 }
327
328 #[test]
329 fn substitute_with_check_detects_unresolved_dollar_brace() {
330 let vars: HashMap<String, String> = HashMap::new();
331 let result = substitute_with_check("${MISSING}", &vars);
332 assert_eq!(result.text, "${MISSING}");
333 assert_eq!(result.unresolved, vec!["MISSING"]);
334 }
335
336 #[test]
337 fn substitute_with_check_detects_unresolved_bare_dollar() {
338 let vars: HashMap<String, String> = HashMap::new();
339 let result = substitute_with_check("$BARE_VAR", &vars);
340 assert_eq!(result.text, "$BARE_VAR");
341 assert_eq!(result.unresolved, vec!["BARE_VAR"]);
342 }
343
344 #[test]
345 fn substitute_with_check_no_unresolved() {
346 let vars = make_vars(&[("HOST", "localhost"), ("PORT", "8080")]);
347 let result = substitute_with_check("{{HOST}}:${PORT}", &vars);
348 assert_eq!(result.text, "localhost:8080");
349 assert!(result.unresolved.is_empty());
350 }
351
352 #[test]
353 fn substitute_with_check_deduplicates() {
354 let vars: HashMap<String, String> = HashMap::new();
355 let result = substitute_with_check("{{X}}/{{X}}/{{X}}", &vars);
356 assert_eq!(result.unresolved, vec!["X"]);
357 }
358
359 #[test]
360 fn substitute_with_check_default_value_not_unresolved() {
361 let vars: HashMap<String, String> = HashMap::new();
362 let result = substitute_with_check("${MISSING:fallback}", &vars);
363 assert_eq!(result.text, "fallback");
364 assert!(result.unresolved.is_empty());
365 }
366
367 #[test]
368 fn substitute_nested_variables() {
369 let vars = make_vars(&[
370 ("ASSET_ID", "{{ASSET_ID_GROUP}}"),
371 ("ASSET_ID_GROUP", "abc123"),
372 ]);
373 assert_eq!(substitute("{{ASSET_ID}}", &vars), "abc123");
374 }
375
376 #[test]
377 fn substitute_nested_three_levels() {
378 let vars = make_vars(&[("A", "{{B}}"), ("B", "{{C}}"), ("C", "final_value")]);
379 assert_eq!(substitute("{{A}}", &vars), "final_value");
380 }
381
382 #[test]
383 fn substitute_nested_circular_stops() {
384 let vars = make_vars(&[("A", "{{B}}"), ("B", "{{A}}")]);
385 let result = substitute("{{A}}", &vars);
387 assert!(result.contains("{{"));
388 }
389
390 #[test]
391 fn substitute_with_check_nested_unresolved() {
392 let vars = make_vars(&[("ASSET_ID", "{{MISSING}}")]);
393 let result = substitute_with_check("{{ASSET_ID}}", &vars);
394 assert_eq!(result.text, "{{MISSING}}");
395 assert_eq!(result.unresolved, vec!["MISSING"]);
396 }
397
398 #[test]
399 fn substitute_nested_mixed_syntax() {
400 let vars = make_vars(&[("A", "$B"), ("B", "resolved")]);
401 assert_eq!(substitute("{{A}}", &vars), "resolved");
402 }
403}