Skip to main content

mur_common/
variable.rs

1//! Variable system — user-defined variables with `{{var_name}}` template syntax.
2//!
3//! Variables are stored in `~/.mur/variables.yaml` and can be referenced in
4//! workflow steps, commands, and descriptions using `{{variable_name}}` syntax.
5//!
6//! ## Variable Resolution Order
7//! 1. CLI overrides (`--var key=value`)
8//! 2. Workflow-level defaults (`variables:` section in workflow YAML)
9//! 3. Global variables (`~/.mur/variables.yaml`)
10//! 4. Environment variables (`$VAR_NAME` → `{{VAR_NAME}}`)
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::PathBuf;
17
18/// A collection of user-defined variables, persisted as `~/.mur/variables.yaml`.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct VariableStore {
21    /// Named variable groups (e.g. "default", "production", "staging")
22    #[serde(default)]
23    pub profiles: BTreeMap<String, BTreeMap<String, String>>,
24
25    /// Active profile name
26    #[serde(default = "default_profile")]
27    pub active_profile: String,
28
29    /// Global variables (always available, overridden by profile)
30    #[serde(default)]
31    pub global: BTreeMap<String, String>,
32}
33
34fn default_profile() -> String {
35    "default".to_string()
36}
37
38impl VariableStore {
39    /// Path to the variables file.
40    pub fn path() -> PathBuf {
41        dirs::home_dir()
42            .expect("no home dir")
43            .join(".mur")
44            .join("variables.yaml")
45    }
46
47    /// Load from disk, or return empty store if file doesn't exist.
48    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    /// Save to disk (atomic write).
61    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    /// Get the effective variables: global merged with active profile.
74    /// Profile values override global values.
75    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    /// Set a variable in global scope.
86    pub fn set_global(&mut self, name: &str, value: &str) {
87        self.global.insert(name.to_string(), value.to_string());
88    }
89
90    /// Set a variable in a specific profile.
91    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    /// Remove a variable from global scope.
99    pub fn remove_global(&mut self, name: &str) -> bool {
100        self.global.remove(name).is_some()
101    }
102
103    /// Remove a variable from a specific profile.
104    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    /// Switch active profile.
112    pub fn switch_profile(&mut self, profile: &str) {
113        self.active_profile = profile.to_string();
114        // Ensure the profile entry exists
115        self.profiles.entry(profile.to_string()).or_default();
116    }
117
118    /// List all profile names.
119    pub fn profile_names(&self) -> Vec<&str> {
120        self.profiles.keys().map(|s| s.as_str()).collect()
121    }
122}
123
124// ─── Template Engine ───────────────────────────────────────────────────────
125
126/// Regex pattern for `{{variable_name}}` — allows alphanumeric, underscore, hyphen, dot.
127fn var_regex() -> regex_lite::Regex {
128    regex_lite::Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_.\-]*)\}\}").unwrap()
129}
130
131/// Resolve all `{{var}}` placeholders in a template string.
132///
133/// Resolution order:
134/// 1. `overrides` (CLI --var flags)
135/// 2. `workflow_defaults` (from workflow's variables section)
136/// 3. `store_vars` (global + active profile from VariableStore)
137/// 4. Environment variables
138///
139/// Unresolved variables are left as-is (with a warning to stderr).
140pub 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    // We need to iterate and replace; using replace_all with closure
151    let resolved = re.replace_all(&result, |caps: &regex_lite::Captures| {
152        let var_name = &caps[1];
153
154        // Skip {{input}} — handled by pipeline executor
155        if var_name == "input" {
156            return caps[0].to_string();
157        }
158
159        // Resolution order
160        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        // Try environment variable (uppercase version)
170        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() // leave as-is
179    });
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
197/// Shell-safe escaping for variable values used in commands.
198fn shell_escape_value(val: &str) -> String {
199    // For display/description contexts, return as-is.
200    // Shell commands get escaped by the pipeline executor via shell-escape.
201    val.to_string()
202}
203
204/// Extract all `{{var}}` names from a template string (excluding `{{input}}`).
205pub 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
217/// Collect all variable references from a workflow's steps and descriptions.
218pub fn collect_workflow_variables(workflow: &crate::workflow::Workflow) -> Vec<String> {
219    let mut all_names = Vec::new();
220
221    // From description
222    all_names.extend(extract_variable_names(&workflow.description));
223
224    // From content
225    all_names.extend(extract_variable_names(&workflow.content.as_text()));
226
227    // From steps
228    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
240/// Build workflow defaults map from a workflow's `variables` section.
241pub 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
251/// Parse CLI `--var key=value` pairs into a map.
252pub 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// ─── Tests ─────────────────────────────────────────────────────────────────
264
265#[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        // SAFETY: test-only, single-threaded context
334        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        // SAFETY: test-only, single-threaded context
345        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"); // profile overrides global
379        assert_eq!(vars.get("db").unwrap(), "global-db"); // global fallback
380    }
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}