use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VariableStore {
#[serde(default)]
pub profiles: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default = "default_profile")]
pub active_profile: String,
#[serde(default)]
pub global: BTreeMap<String, String>,
}
fn default_profile() -> String {
"default".to_string()
}
impl VariableStore {
pub fn path() -> PathBuf {
dirs::home_dir()
.expect("no home dir")
.join(".mur")
.join("variables.yaml")
}
pub fn load() -> Result<Self> {
let path = Self::path();
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read variables file: {}", path.display()))?;
let store: Self = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse variables YAML: {}", path.display()))?;
Ok(store)
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let yaml = serde_yaml::to_string(self)?;
let tmp = path.with_extension("yaml.tmp");
fs::write(&tmp, &yaml)?;
fs::rename(&tmp, &path)?;
Ok(())
}
pub fn effective_vars(&self) -> BTreeMap<String, String> {
let mut vars = self.global.clone();
if let Some(profile_vars) = self.profiles.get(&self.active_profile) {
for (k, v) in profile_vars {
vars.insert(k.clone(), v.clone());
}
}
vars
}
pub fn set_global(&mut self, name: &str, value: &str) {
self.global.insert(name.to_string(), value.to_string());
}
pub fn set_profile(&mut self, profile: &str, name: &str, value: &str) {
self.profiles
.entry(profile.to_string())
.or_default()
.insert(name.to_string(), value.to_string());
}
pub fn remove_global(&mut self, name: &str) -> bool {
self.global.remove(name).is_some()
}
pub fn remove_profile(&mut self, profile: &str, name: &str) -> bool {
if let Some(profile_vars) = self.profiles.get_mut(profile) {
return profile_vars.remove(name).is_some();
}
false
}
pub fn switch_profile(&mut self, profile: &str) {
self.active_profile = profile.to_string();
self.profiles.entry(profile.to_string()).or_default();
}
pub fn profile_names(&self) -> Vec<&str> {
self.profiles.keys().map(|s| s.as_str()).collect()
}
}
fn var_regex() -> regex_lite::Regex {
regex_lite::Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_.\-]*)\}\}").unwrap()
}
pub fn resolve_variables(
template: &str,
overrides: &BTreeMap<String, String>,
workflow_defaults: &BTreeMap<String, String>,
store_vars: &BTreeMap<String, String>,
) -> String {
let re = var_regex();
let mut result = template.to_string();
let mut unresolved = Vec::new();
let resolved = re.replace_all(&result, |caps: ®ex_lite::Captures| {
let var_name = &caps[1];
if var_name == "input" {
return caps[0].to_string();
}
if let Some(val) = overrides.get(var_name) {
return shell_escape_value(val);
}
if let Some(val) = workflow_defaults.get(var_name) {
return shell_escape_value(val);
}
if let Some(val) = store_vars.get(var_name) {
return shell_escape_value(val);
}
if let Ok(val) = std::env::var(var_name) {
return shell_escape_value(&val);
}
if let Ok(val) = std::env::var(var_name.to_uppercase()) {
return shell_escape_value(&val);
}
unresolved.push(var_name.to_string());
caps[0].to_string() });
result = resolved.into_owned();
if !unresolved.is_empty() {
eprintln!(
"⚠ Unresolved variables: {}",
unresolved
.iter()
.map(|v| format!("{{{{{}}}}}", v))
.collect::<Vec<_>>()
.join(", ")
);
}
result
}
fn shell_escape_value(val: &str) -> String {
val.to_string()
}
pub fn extract_variable_names(template: &str) -> Vec<String> {
let re = var_regex();
let mut names: Vec<String> = re
.captures_iter(template)
.map(|c| c[1].to_string())
.filter(|n| n != "input")
.collect();
names.sort();
names.dedup();
names
}
pub fn collect_workflow_variables(workflow: &crate::workflow::Workflow) -> Vec<String> {
let mut all_names = Vec::new();
all_names.extend(extract_variable_names(&workflow.description));
all_names.extend(extract_variable_names(&workflow.content.as_text()));
for step in &workflow.steps {
all_names.extend(extract_variable_names(&step.description));
if let Some(ref cmd) = step.command {
all_names.extend(extract_variable_names(cmd));
}
}
all_names.sort();
all_names.dedup();
all_names
}
pub fn workflow_defaults_map(workflow: &crate::workflow::Workflow) -> BTreeMap<String, String> {
let mut defaults = BTreeMap::new();
for v in &workflow.variables {
if let Some(ref dv) = v.default_value {
defaults.insert(v.name.clone(), dv.clone());
}
}
defaults
}
pub fn parse_var_overrides(pairs: &[String]) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
for pair in pairs {
let (key, value) = pair
.split_once('=')
.with_context(|| format!("Invalid --var format '{}', expected key=value", pair))?;
map.insert(key.trim().to_string(), value.trim().to_string());
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_variable_names() {
let names = extract_variable_names("Deploy {{app_name}} to {{site_url}} with {{input}}");
assert_eq!(names, vec!["app_name", "site_url"]);
}
#[test]
fn test_extract_no_variables() {
let names = extract_variable_names("No variables here");
assert!(names.is_empty());
}
#[test]
fn test_resolve_from_overrides() {
let overrides = BTreeMap::from([("name".into(), "myapp".into())]);
let result = resolve_variables(
"Deploy {{name}}",
&overrides,
&BTreeMap::new(),
&BTreeMap::new(),
);
assert_eq!(result, "Deploy myapp");
}
#[test]
fn test_resolve_priority_order() {
let overrides = BTreeMap::from([("x".into(), "override".into())]);
let defaults = BTreeMap::from([("x".into(), "default".into())]);
let store = BTreeMap::from([("x".into(), "global".into())]);
let result = resolve_variables("{{x}}", &overrides, &defaults, &store);
assert_eq!(result, "override");
let result = resolve_variables("{{x}}", &BTreeMap::new(), &defaults, &store);
assert_eq!(result, "default");
let result = resolve_variables("{{x}}", &BTreeMap::new(), &BTreeMap::new(), &store);
assert_eq!(result, "global");
}
#[test]
fn test_resolve_leaves_input_alone() {
let result = resolve_variables(
"echo {{input}} and {{name}}",
&BTreeMap::from([("name".into(), "test".into())]),
&BTreeMap::new(),
&BTreeMap::new(),
);
assert_eq!(result, "echo {{input}} and test");
}
#[test]
fn test_resolve_unresolved_left_as_is() {
let result = resolve_variables(
"{{known}} and {{unknown}}",
&BTreeMap::from([("known".into(), "yes".into())]),
&BTreeMap::new(),
&BTreeMap::new(),
);
assert_eq!(result, "yes and {{unknown}}");
}
#[test]
fn test_resolve_env_var() {
unsafe {
std::env::set_var("MUR_TEST_VAR_XYZ", "from_env");
}
let result = resolve_variables(
"{{MUR_TEST_VAR_XYZ}}",
&BTreeMap::new(),
&BTreeMap::new(),
&BTreeMap::new(),
);
assert_eq!(result, "from_env");
unsafe {
std::env::remove_var("MUR_TEST_VAR_XYZ");
}
}
#[test]
fn test_parse_var_overrides() {
let pairs = vec!["name=myapp".into(), "url=https://example.com".into()];
let map = parse_var_overrides(&pairs).unwrap();
assert_eq!(map.get("name").unwrap(), "myapp");
assert_eq!(map.get("url").unwrap(), "https://example.com");
}
#[test]
fn test_parse_var_overrides_invalid() {
let pairs = vec!["bad_format".into()];
assert!(parse_var_overrides(&pairs).is_err());
}
#[test]
fn test_variable_store_effective_vars() {
let store = VariableStore {
global: BTreeMap::from([
("site".into(), "global.com".into()),
("db".into(), "global-db".into()),
]),
profiles: BTreeMap::from([(
"production".into(),
BTreeMap::from([("site".into(), "prod.com".into())]),
)]),
active_profile: "production".into(),
};
let vars = store.effective_vars();
assert_eq!(vars.get("site").unwrap(), "prod.com"); assert_eq!(vars.get("db").unwrap(), "global-db"); }
#[test]
fn test_multiple_same_variable() {
let overrides = BTreeMap::from([("x".into(), "val".into())]);
let result = resolve_variables(
"{{x}} and {{x}} again",
&overrides,
&BTreeMap::new(),
&BTreeMap::new(),
);
assert_eq!(result, "val and val again");
}
}