1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::PathBuf;
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct VariableStore {
21 #[serde(default)]
23 pub profiles: BTreeMap<String, BTreeMap<String, String>>,
24
25 #[serde(default = "default_profile")]
27 pub active_profile: String,
28
29 #[serde(default)]
31 pub global: BTreeMap<String, String>,
32}
33
34fn default_profile() -> String {
35 "default".to_string()
36}
37
38impl VariableStore {
39 pub fn path() -> PathBuf {
41 crate::home::mur_home().join("variables.yaml")
42 }
43
44 pub fn load() -> Result<Self> {
46 let path = Self::path();
47 if !path.exists() {
48 return Ok(Self::default());
49 }
50 let content = fs::read_to_string(&path)
51 .with_context(|| format!("Failed to read variables file: {}", path.display()))?;
52 let store: Self = serde_yaml::from_str(&content)
53 .with_context(|| format!("Failed to parse variables YAML: {}", path.display()))?;
54 Ok(store)
55 }
56
57 pub fn save(&self) -> Result<()> {
59 let path = Self::path();
60 if let Some(parent) = path.parent() {
61 fs::create_dir_all(parent)?;
62 }
63 let yaml = serde_yaml::to_string(self)?;
64 let tmp = path.with_extension("yaml.tmp");
65 fs::write(&tmp, &yaml)?;
66 fs::rename(&tmp, &path)?;
67 Ok(())
68 }
69
70 pub fn effective_vars(&self) -> BTreeMap<String, String> {
73 let mut vars = self.global.clone();
74 if let Some(profile_vars) = self.profiles.get(&self.active_profile) {
75 for (k, v) in profile_vars {
76 vars.insert(k.clone(), v.clone());
77 }
78 }
79 vars
80 }
81
82 pub fn set_global(&mut self, name: &str, value: &str) {
84 self.global.insert(name.to_string(), value.to_string());
85 }
86
87 pub fn set_profile(&mut self, profile: &str, name: &str, value: &str) {
89 self.profiles
90 .entry(profile.to_string())
91 .or_default()
92 .insert(name.to_string(), value.to_string());
93 }
94
95 pub fn remove_global(&mut self, name: &str) -> bool {
97 self.global.remove(name).is_some()
98 }
99
100 pub fn remove_profile(&mut self, profile: &str, name: &str) -> bool {
102 if let Some(profile_vars) = self.profiles.get_mut(profile) {
103 return profile_vars.remove(name).is_some();
104 }
105 false
106 }
107
108 pub fn switch_profile(&mut self, profile: &str) {
110 self.active_profile = profile.to_string();
111 self.profiles.entry(profile.to_string()).or_default();
113 }
114
115 pub fn profile_names(&self) -> Vec<&str> {
117 self.profiles.keys().map(|s| s.as_str()).collect()
118 }
119}
120
121fn var_regex() -> regex_lite::Regex {
125 regex_lite::Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_.\-]*)\}\}").unwrap()
126}
127
128pub fn resolve_variables(
138 template: &str,
139 overrides: &BTreeMap<String, String>,
140 workflow_defaults: &BTreeMap<String, String>,
141 store_vars: &BTreeMap<String, String>,
142) -> String {
143 let re = var_regex();
144 let mut result = template.to_string();
145 let mut unresolved = Vec::new();
146
147 let resolved = re.replace_all(&result, |caps: ®ex_lite::Captures| {
149 let var_name = &caps[1];
150
151 if var_name == "input" {
153 return caps[0].to_string();
154 }
155
156 if let Some(val) = overrides.get(var_name) {
158 return shell_escape_value(val);
159 }
160 if let Some(val) = workflow_defaults.get(var_name) {
161 return shell_escape_value(val);
162 }
163 if let Some(val) = store_vars.get(var_name) {
164 return shell_escape_value(val);
165 }
166 if let Ok(val) = std::env::var(var_name) {
168 return shell_escape_value(&val);
169 }
170 if let Ok(val) = std::env::var(var_name.to_uppercase()) {
171 return shell_escape_value(&val);
172 }
173
174 unresolved.push(var_name.to_string());
175 caps[0].to_string() });
177
178 result = resolved.into_owned();
179
180 if !unresolved.is_empty() {
181 eprintln!(
182 "⚠ Unresolved variables: {}",
183 unresolved
184 .iter()
185 .map(|v| format!("{{{{{}}}}}", v))
186 .collect::<Vec<_>>()
187 .join(", ")
188 );
189 }
190
191 result
192}
193
194fn shell_escape_value(val: &str) -> String {
196 val.to_string()
199}
200
201pub fn extract_variable_names(template: &str) -> Vec<String> {
203 let re = var_regex();
204 let mut names: Vec<String> = re
205 .captures_iter(template)
206 .map(|c| c[1].to_string())
207 .filter(|n| n != "input")
208 .collect();
209 names.sort();
210 names.dedup();
211 names
212}
213
214pub fn collect_workflow_variables(workflow: &crate::workflow::Workflow) -> Vec<String> {
216 let mut all_names = Vec::new();
217
218 all_names.extend(extract_variable_names(&workflow.description));
220
221 all_names.extend(extract_variable_names(&workflow.content.as_text()));
223
224 for step in &workflow.steps {
226 all_names.extend(extract_variable_names(&step.description));
227 if let Some(ref cmd) = step.command {
228 all_names.extend(extract_variable_names(cmd));
229 }
230 }
231
232 all_names.sort();
233 all_names.dedup();
234 all_names
235}
236
237pub fn workflow_defaults_map(workflow: &crate::workflow::Workflow) -> BTreeMap<String, String> {
239 let mut defaults = BTreeMap::new();
240 for v in &workflow.variables {
241 if let Some(ref dv) = v.default {
242 defaults.insert(v.name.clone(), dv.clone());
243 }
244 }
245 defaults
246}
247
248pub fn parse_var_overrides(pairs: &[String]) -> Result<BTreeMap<String, String>> {
250 let mut map = BTreeMap::new();
251 for pair in pairs {
252 let (key, value) = pair
253 .split_once('=')
254 .with_context(|| format!("Invalid --var format '{}', expected key=value", pair))?;
255 map.insert(key.trim().to_string(), value.trim().to_string());
256 }
257 Ok(map)
258}
259
260#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_extract_variable_names() {
268 let names = extract_variable_names("Deploy {{app_name}} to {{site_url}} with {{input}}");
269 assert_eq!(names, vec!["app_name", "site_url"]);
270 }
271
272 #[test]
273 fn test_extract_no_variables() {
274 let names = extract_variable_names("No variables here");
275 assert!(names.is_empty());
276 }
277
278 #[test]
279 fn test_resolve_from_overrides() {
280 let overrides = BTreeMap::from([("name".into(), "myapp".into())]);
281 let result = resolve_variables(
282 "Deploy {{name}}",
283 &overrides,
284 &BTreeMap::new(),
285 &BTreeMap::new(),
286 );
287 assert_eq!(result, "Deploy myapp");
288 }
289
290 #[test]
291 fn test_resolve_priority_order() {
292 let overrides = BTreeMap::from([("x".into(), "override".into())]);
293 let defaults = BTreeMap::from([("x".into(), "default".into())]);
294 let store = BTreeMap::from([("x".into(), "global".into())]);
295
296 let result = resolve_variables("{{x}}", &overrides, &defaults, &store);
297 assert_eq!(result, "override");
298
299 let result = resolve_variables("{{x}}", &BTreeMap::new(), &defaults, &store);
300 assert_eq!(result, "default");
301
302 let result = resolve_variables("{{x}}", &BTreeMap::new(), &BTreeMap::new(), &store);
303 assert_eq!(result, "global");
304 }
305
306 #[test]
307 fn test_resolve_leaves_input_alone() {
308 let result = resolve_variables(
309 "echo {{input}} and {{name}}",
310 &BTreeMap::from([("name".into(), "test".into())]),
311 &BTreeMap::new(),
312 &BTreeMap::new(),
313 );
314 assert_eq!(result, "echo {{input}} and test");
315 }
316
317 #[test]
318 fn test_resolve_unresolved_left_as_is() {
319 let result = resolve_variables(
320 "{{known}} and {{unknown}}",
321 &BTreeMap::from([("known".into(), "yes".into())]),
322 &BTreeMap::new(),
323 &BTreeMap::new(),
324 );
325 assert_eq!(result, "yes and {{unknown}}");
326 }
327
328 #[test]
329 fn test_resolve_env_var() {
330 let _env = crate::test_env::EnvGuard::set([("MUR_TEST_VAR_XYZ", "from_env")]);
331 let result = resolve_variables(
332 "{{MUR_TEST_VAR_XYZ}}",
333 &BTreeMap::new(),
334 &BTreeMap::new(),
335 &BTreeMap::new(),
336 );
337 assert_eq!(result, "from_env");
338 }
339
340 #[test]
341 fn test_parse_var_overrides() {
342 let pairs = vec!["name=myapp".into(), "url=https://example.com".into()];
343 let map = parse_var_overrides(&pairs).unwrap();
344 assert_eq!(map.get("name").unwrap(), "myapp");
345 assert_eq!(map.get("url").unwrap(), "https://example.com");
346 }
347
348 #[test]
349 fn test_parse_var_overrides_invalid() {
350 let pairs = vec!["bad_format".into()];
351 assert!(parse_var_overrides(&pairs).is_err());
352 }
353
354 #[test]
355 fn test_variable_store_effective_vars() {
356 let store = VariableStore {
357 global: BTreeMap::from([
358 ("site".into(), "global.com".into()),
359 ("db".into(), "global-db".into()),
360 ]),
361 profiles: BTreeMap::from([(
362 "production".into(),
363 BTreeMap::from([("site".into(), "prod.com".into())]),
364 )]),
365 active_profile: "production".into(),
366 };
367 let vars = store.effective_vars();
368 assert_eq!(vars.get("site").unwrap(), "prod.com"); assert_eq!(vars.get("db").unwrap(), "global-db"); }
371
372 #[test]
373 fn test_multiple_same_variable() {
374 let overrides = BTreeMap::from([("x".into(), "val".into())]);
375 let result = resolve_variables(
376 "{{x}} and {{x}} again",
377 &overrides,
378 &BTreeMap::new(),
379 &BTreeMap::new(),
380 );
381 assert_eq!(result, "val and val again");
382 }
383}