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        crate::home::mur_home().join("variables.yaml")
42    }
43
44    /// Load from disk, or return empty store if file doesn't exist.
45    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    /// Save to disk (atomic write).
58    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    /// Get the effective variables: global merged with active profile.
71    /// Profile values override global values.
72    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    /// Set a variable in global scope.
83    pub fn set_global(&mut self, name: &str, value: &str) {
84        self.global.insert(name.to_string(), value.to_string());
85    }
86
87    /// Set a variable in a specific profile.
88    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    /// Remove a variable from global scope.
96    pub fn remove_global(&mut self, name: &str) -> bool {
97        self.global.remove(name).is_some()
98    }
99
100    /// Remove a variable from a specific profile.
101    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    /// Switch active profile.
109    pub fn switch_profile(&mut self, profile: &str) {
110        self.active_profile = profile.to_string();
111        // Ensure the profile entry exists
112        self.profiles.entry(profile.to_string()).or_default();
113    }
114
115    /// List all profile names.
116    pub fn profile_names(&self) -> Vec<&str> {
117        self.profiles.keys().map(|s| s.as_str()).collect()
118    }
119}
120
121// ─── Template Engine ───────────────────────────────────────────────────────
122
123/// Regex pattern for `{{variable_name}}` — allows alphanumeric, underscore, hyphen, dot.
124fn var_regex() -> regex_lite::Regex {
125    regex_lite::Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_.\-]*)\}\}").unwrap()
126}
127
128/// Resolve all `{{var}}` placeholders in a template string.
129///
130/// Resolution order:
131/// 1. `overrides` (CLI --var flags)
132/// 2. `workflow_defaults` (from workflow's variables section)
133/// 3. `store_vars` (global + active profile from VariableStore)
134/// 4. Environment variables
135///
136/// Unresolved variables are left as-is (with a warning to stderr).
137pub 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    // We need to iterate and replace; using replace_all with closure
148    let resolved = re.replace_all(&result, |caps: &regex_lite::Captures| {
149        let var_name = &caps[1];
150
151        // Skip {{input}} — handled by pipeline executor
152        if var_name == "input" {
153            return caps[0].to_string();
154        }
155
156        // Resolution order
157        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        // Try environment variable (uppercase version)
167        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() // leave as-is
176    });
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
194/// Shell-safe escaping for variable values used in commands.
195fn shell_escape_value(val: &str) -> String {
196    // For display/description contexts, return as-is.
197    // Shell commands get escaped by the pipeline executor via shell-escape.
198    val.to_string()
199}
200
201/// Extract all `{{var}}` names from a template string (excluding `{{input}}`).
202pub 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
214/// Collect all variable references from a workflow's steps and descriptions.
215pub fn collect_workflow_variables(workflow: &crate::workflow::Workflow) -> Vec<String> {
216    let mut all_names = Vec::new();
217
218    // From description
219    all_names.extend(extract_variable_names(&workflow.description));
220
221    // From content
222    all_names.extend(extract_variable_names(&workflow.content.as_text()));
223
224    // From steps
225    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
237/// Build workflow defaults map from a workflow's `variables` section.
238pub 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
248/// Parse CLI `--var key=value` pairs into a map.
249pub 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// ─── Tests ─────────────────────────────────────────────────────────────────
261
262#[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"); // profile overrides global
369        assert_eq!(vars.get("db").unwrap(), "global-db"); // global fallback
370    }
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}