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_value {
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 unsafe {
335 std::env::set_var("MUR_TEST_VAR_XYZ", "from_env");
336 }
337 let result = resolve_variables(
338 "{{MUR_TEST_VAR_XYZ}}",
339 &BTreeMap::new(),
340 &BTreeMap::new(),
341 &BTreeMap::new(),
342 );
343 assert_eq!(result, "from_env");
344 unsafe {
346 std::env::remove_var("MUR_TEST_VAR_XYZ");
347 }
348 }
349
350 #[test]
351 fn test_parse_var_overrides() {
352 let pairs = vec!["name=myapp".into(), "url=https://example.com".into()];
353 let map = parse_var_overrides(&pairs).unwrap();
354 assert_eq!(map.get("name").unwrap(), "myapp");
355 assert_eq!(map.get("url").unwrap(), "https://example.com");
356 }
357
358 #[test]
359 fn test_parse_var_overrides_invalid() {
360 let pairs = vec!["bad_format".into()];
361 assert!(parse_var_overrides(&pairs).is_err());
362 }
363
364 #[test]
365 fn test_variable_store_effective_vars() {
366 let store = VariableStore {
367 global: BTreeMap::from([
368 ("site".into(), "global.com".into()),
369 ("db".into(), "global-db".into()),
370 ]),
371 profiles: BTreeMap::from([(
372 "production".into(),
373 BTreeMap::from([("site".into(), "prod.com".into())]),
374 )]),
375 active_profile: "production".into(),
376 };
377 let vars = store.effective_vars();
378 assert_eq!(vars.get("site").unwrap(), "prod.com"); assert_eq!(vars.get("db").unwrap(), "global-db"); }
381
382 #[test]
383 fn test_multiple_same_variable() {
384 let overrides = BTreeMap::from([("x".into(), "val".into())]);
385 let result = resolve_variables(
386 "{{x}} and {{x}} again",
387 &overrides,
388 &BTreeMap::new(),
389 &BTreeMap::new(),
390 );
391 assert_eq!(result, "val and val again");
392 }
393}