Skip to main content

mur_core/workflow/
parser.rs

1//! Workflow YAML parser — loads workflow definitions from `~/.mur/workflows/*.yaml`.
2
3use crate::types::{FailureAction, Step, StepType, Workflow};
4use anyhow::{Context, Result};
5use chrono::Utc;
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// Raw YAML representation of a workflow (deserialization target).
11#[derive(Debug, Deserialize)]
12pub struct WorkflowYaml {
13    pub id: String,
14    pub name: String,
15    #[serde(default)]
16    pub description: String,
17    #[serde(default)]
18    pub variables: HashMap<String, String>,
19    /// Optional cron expression for scheduled execution (e.g., "0 2 * * *").
20    #[serde(default)]
21    pub schedule: Option<String>,
22    pub steps: Vec<StepYaml>,
23}
24
25/// Raw YAML representation of a step.
26#[derive(Debug, Deserialize)]
27pub struct StepYaml {
28    pub name: String,
29    #[serde(default = "default_step_type")]
30    pub step_type: String,
31    pub action: String,
32    #[serde(default = "default_on_failure")]
33    pub on_failure: String,
34    #[serde(default)]
35    pub on_failure_max: Option<u32>,
36    #[serde(default)]
37    pub breakpoint: bool,
38    #[serde(default)]
39    pub breakpoint_message: Option<String>,
40}
41
42fn default_step_type() -> String {
43    "execute".into()
44}
45
46fn default_on_failure() -> String {
47    "abort".into()
48}
49
50/// Resolve `{{variable}}` placeholders in a string.
51pub fn resolve_variables(input: &str, vars: &HashMap<String, String>) -> String {
52    let mut result = input.to_string();
53    for (key, value) in vars {
54        result = result.replace(&format!("{{{{{}}}}}", key), value);
55    }
56    result
57}
58
59/// Parse a step type string into `StepType`.
60fn parse_step_type(s: &str) -> StepType {
61    match s {
62        "analyze" => StepType::Analyze,
63        "plan" => StepType::Plan,
64        "debug" => StepType::Debug,
65        "code" => StepType::Code,
66        "refactor" => StepType::Refactor,
67        "fix" => StepType::Fix,
68        "search" => StepType::Search,
69        "classify" => StepType::Classify,
70        "summarize" => StepType::Summarize,
71        "security_check" => StepType::SecurityCheck,
72        "execute" => StepType::Execute,
73        _ => StepType::Other,
74    }
75}
76
77/// Parse a failure action string into `FailureAction`.
78fn parse_failure_action(s: &str, max: Option<u32>) -> FailureAction {
79    match s {
80        "abort" => FailureAction::Abort,
81        "skip" => FailureAction::Skip,
82        "retry" => FailureAction::Retry {
83            max: max.unwrap_or(3),
84        },
85        "auto_fix" | "autofix" => FailureAction::AutoFix,
86        _ => FailureAction::Abort,
87    }
88}
89
90/// Convert a `StepYaml` into a `Step`.
91pub fn step_yaml_to_step(s: StepYaml) -> Step {
92    Step {
93        name: s.name,
94        step_type: parse_step_type(&s.step_type),
95        action: s.action,
96        on_failure: parse_failure_action(&s.on_failure, s.on_failure_max),
97        breakpoint: s.breakpoint,
98        breakpoint_message: s.breakpoint_message,
99    }
100}
101
102/// Convert raw YAML workflow into internal `Workflow` type.
103fn yaml_to_workflow(raw: WorkflowYaml) -> Workflow {
104    let now = Utc::now();
105    Workflow {
106        id: raw.id,
107        name: raw.name,
108        description: raw.description,
109        variables: raw.variables,
110        schedule: raw.schedule,
111        steps: raw
112            .steps
113            .into_iter()
114            .map(step_yaml_to_step)
115            .collect(),
116        created_at: now,
117        updated_at: now,
118    }
119}
120
121/// Parse a single workflow YAML file.
122pub fn parse_workflow_file(path: &Path) -> Result<Workflow> {
123    let content =
124        std::fs::read_to_string(path).with_context(|| format!("Reading {:?}", path))?;
125    let raw: WorkflowYaml =
126        serde_yaml::from_str(&content).with_context(|| format!("Parsing YAML {:?}", path))?;
127    Ok(yaml_to_workflow(raw))
128}
129
130/// Parse a workflow from a YAML string.
131pub fn parse_workflow_str(yaml: &str) -> Result<Workflow> {
132    let raw: WorkflowYaml = serde_yaml::from_str(yaml).context("Parsing workflow YAML")?;
133    Ok(yaml_to_workflow(raw))
134}
135
136/// Get the default workflows directory.
137pub fn workflows_dir() -> PathBuf {
138    dirs_path().join("workflows")
139}
140
141fn dirs_path() -> PathBuf {
142    directories::BaseDirs::new()
143        .map(|d| d.home_dir().join(".mur"))
144        .unwrap_or_else(|| PathBuf::from(".mur"))
145}
146
147/// List all workflow YAML files in the workflows directory.
148pub fn list_workflow_files() -> Result<Vec<PathBuf>> {
149    let dir = workflows_dir();
150    if !dir.exists() {
151        return Ok(vec![]);
152    }
153    let pattern = dir.join("*.yaml").to_string_lossy().to_string();
154    let mut files: Vec<PathBuf> = glob::glob(&pattern)
155        .context("Invalid glob pattern")?
156        .filter_map(|e| e.ok())
157        .collect();
158    // Also check .yml
159    let pattern2 = dir.join("*.yml").to_string_lossy().to_string();
160    files.extend(
161        glob::glob(&pattern2)
162            .context("Invalid glob pattern")?
163            .filter_map(|e| e.ok()),
164    );
165    files.sort();
166    Ok(files)
167}
168
169/// Load all workflows from the default directory.
170pub fn load_all_workflows() -> Result<Vec<Workflow>> {
171    let files = list_workflow_files()?;
172    let mut workflows = Vec::new();
173    for file in files {
174        match parse_workflow_file(&file) {
175            Ok(w) => workflows.push(w),
176            Err(e) => tracing::warn!("Skipping {:?}: {}", file, e),
177        }
178    }
179    Ok(workflows)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    const SAMPLE_YAML: &str = r#"
187id: test-workflow
188name: Test Workflow
189description: A test workflow
190variables:
191  server: localhost
192  port: "8080"
193steps:
194  - name: check-health
195    step_type: execute
196    action: "curl http://{{server}}:{{port}}/health"
197    on_failure: retry
198    on_failure_max: 3
199    breakpoint: false
200  - name: deploy
201    step_type: execute
202    action: "docker compose up -d"
203    on_failure: abort
204    breakpoint: true
205    breakpoint_message: "About to deploy. Confirm?"
206"#;
207
208    #[test]
209    fn test_parse_workflow() {
210        let wf = parse_workflow_str(SAMPLE_YAML).unwrap();
211        assert_eq!(wf.id, "test-workflow");
212        assert_eq!(wf.name, "Test Workflow");
213        assert_eq!(wf.steps.len(), 2);
214        assert_eq!(wf.variables.get("server").unwrap(), "localhost");
215
216        // Check first step
217        assert_eq!(wf.steps[0].name, "check-health");
218        assert_eq!(wf.steps[0].step_type, StepType::Execute);
219        assert_eq!(
220            wf.steps[0].on_failure,
221            FailureAction::Retry { max: 3 }
222        );
223        assert!(!wf.steps[0].breakpoint);
224
225        // Check second step
226        assert!(wf.steps[1].breakpoint);
227        assert_eq!(
228            wf.steps[1].breakpoint_message.as_deref(),
229            Some("About to deploy. Confirm?")
230        );
231    }
232
233    #[test]
234    fn test_resolve_variables() {
235        let mut vars = HashMap::new();
236        vars.insert("host".into(), "example.com".into());
237        vars.insert("port".into(), "443".into());
238
239        let result = resolve_variables("https://{{host}}:{{port}}/api", &vars);
240        assert_eq!(result, "https://example.com:443/api");
241    }
242
243    #[test]
244    fn test_resolve_variables_missing() {
245        let vars = HashMap::new();
246        let result = resolve_variables("{{missing}} stays", &vars);
247        assert_eq!(result, "{{missing}} stays");
248    }
249}