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