mur_core/workflow/
parser.rs1use 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#[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 #[serde(default)]
21 pub schedule: Option<String>,
22 pub steps: Vec<StepYaml>,
23}
24
25#[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
50pub 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
59fn 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
77fn 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
90pub 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
102fn 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
121pub 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
130pub 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
136pub 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
147pub 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 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
169pub 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 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 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}