Skip to main content

mdvault_core/macros/
runner.rs

1//! Macro runner for executing multi-step workflows.
2
3use std::collections::HashMap;
4
5use thiserror::Error;
6
7use super::types::{
8    CaptureStep, ErrorPolicy, LoadedMacro, MacroResult, MacroSpec, MacroStep, ShellStep,
9    StepResult, TemplateStep,
10};
11use crate::templates::engine::render_string;
12
13/// Error type for macro execution.
14#[derive(Debug, Error)]
15pub enum MacroRunError {
16    #[error("step {step} failed: {message}")]
17    StepFailed { step: usize, message: String },
18
19    #[error("shell execution requires --trust flag")]
20    TrustRequired,
21
22    #[error("shell execution is disabled in config")]
23    ShellDisabled,
24
25    #[error("template error: {0}")]
26    TemplateError(String),
27
28    #[error("capture error: {0}")]
29    CaptureError(String),
30
31    #[error("shell error: {0}")]
32    ShellError(String),
33
34    #[error("variable error: {0}")]
35    VariableError(String),
36}
37
38/// Options for macro execution.
39#[derive(Debug, Clone, Default)]
40pub struct RunOptions {
41    /// Whether the --trust flag was provided.
42    pub trust: bool,
43
44    /// Whether shell execution is allowed by config.
45    pub allow_shell: bool,
46
47    /// Whether to run in dry-run mode (no actual changes).
48    pub dry_run: bool,
49}
50
51/// Context passed to step executors.
52#[derive(Debug, Clone)]
53pub struct RunContext {
54    /// Current variable values (macro vars + step overrides).
55    pub vars: HashMap<String, String>,
56
57    /// Execution options.
58    pub options: RunOptions,
59
60    /// Results from previous steps (for chaining).
61    pub previous_results: Vec<StepResult>,
62}
63
64impl RunContext {
65    /// Create a new run context with initial variables.
66    pub fn new(vars: HashMap<String, String>, options: RunOptions) -> Self {
67        Self { vars, options, previous_results: Vec::new() }
68    }
69
70    /// Merge step-level variable overrides into context.
71    pub fn with_step_vars(
72        &self,
73        step_vars: &HashMap<String, String>,
74    ) -> HashMap<String, String> {
75        let mut merged = self.vars.clone();
76
77        // Render step vars (they may reference macro vars)
78        for (key, value) in step_vars {
79            let rendered =
80                render_string(value, &merged).unwrap_or_else(|_| value.clone());
81            merged.insert(key.clone(), rendered);
82        }
83
84        merged
85    }
86
87    /// Add a step result to the context.
88    pub fn add_result(&mut self, result: StepResult) {
89        // If the step created a file, add it as a variable for subsequent steps
90        if let Some(ref path) = result.output_path {
91            let var_name = format!("step_{}_output", result.step_index);
92            self.vars.insert(var_name, path.to_string_lossy().to_string());
93        }
94        self.previous_results.push(result);
95    }
96}
97
98/// Trait for executing individual macro steps.
99///
100/// This allows the CLI/TUI to provide their own implementations
101/// that integrate with their error handling and UI.
102pub trait StepExecutor {
103    /// Execute a template step.
104    fn execute_template(
105        &self,
106        step: &TemplateStep,
107        ctx: &RunContext,
108    ) -> Result<StepResult, MacroRunError>;
109
110    /// Execute a capture step.
111    fn execute_capture(
112        &self,
113        step: &CaptureStep,
114        ctx: &RunContext,
115    ) -> Result<StepResult, MacroRunError>;
116
117    /// Execute a shell step.
118    fn execute_shell(
119        &self,
120        step: &ShellStep,
121        ctx: &RunContext,
122    ) -> Result<StepResult, MacroRunError>;
123}
124
125/// Run a macro with the given executor and context.
126pub fn run_macro<E: StepExecutor>(
127    loaded: &LoadedMacro,
128    executor: &E,
129    mut ctx: RunContext,
130) -> MacroResult {
131    let spec = &loaded.spec;
132    let mut all_success = true;
133    let mut step_results = Vec::new();
134
135    for (index, step) in spec.steps.iter().enumerate() {
136        let result = execute_step(executor, step, index, &ctx);
137
138        match result {
139            Ok(step_result) => {
140                ctx.add_result(step_result.clone());
141                step_results.push(step_result);
142            }
143            Err(e) => {
144                all_success = false;
145                let error_result = StepResult {
146                    step_index: index,
147                    success: false,
148                    message: e.to_string(),
149                    output_path: None,
150                };
151                step_results.push(error_result);
152
153                // Check error policy
154                if spec.on_error == ErrorPolicy::Abort {
155                    break;
156                }
157            }
158        }
159    }
160
161    let message = if all_success {
162        format!("Completed {} steps successfully", step_results.len())
163    } else {
164        let failed_count = step_results.iter().filter(|r| !r.success).count();
165        format!(
166            "Completed with {} failures out of {} steps",
167            failed_count,
168            step_results.len()
169        )
170    };
171
172    MacroResult {
173        macro_name: loaded.logical_name.clone(),
174        step_results,
175        success: all_success,
176        message,
177    }
178}
179
180fn execute_step<E: StepExecutor>(
181    executor: &E,
182    step: &MacroStep,
183    _index: usize,
184    ctx: &RunContext,
185) -> Result<StepResult, MacroRunError> {
186    // Check trust requirements for shell steps
187    if step.requires_trust() {
188        if !ctx.options.trust {
189            return Err(MacroRunError::TrustRequired);
190        }
191        if !ctx.options.allow_shell {
192            return Err(MacroRunError::ShellDisabled);
193        }
194    }
195
196    match step {
197        MacroStep::Template(t) => executor.execute_template(t, ctx),
198        MacroStep::Capture(c) => executor.execute_capture(c, ctx),
199        MacroStep::Shell(s) => executor.execute_shell(s, ctx),
200    }
201}
202
203/// Check if a macro contains any steps that require trust.
204pub fn requires_trust(spec: &MacroSpec) -> bool {
205    spec.steps.iter().any(|s| s.requires_trust())
206}
207
208/// Get descriptions of all shell commands in a macro.
209pub fn get_shell_commands(spec: &MacroSpec) -> Vec<String> {
210    spec.steps
211        .iter()
212        .filter_map(|s| match s {
213            MacroStep::Shell(shell) => Some(shell.shell.clone()),
214            _ => None,
215        })
216        .collect()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::path::PathBuf;
223
224    struct MockExecutor;
225
226    impl StepExecutor for MockExecutor {
227        fn execute_template(
228            &self,
229            step: &TemplateStep,
230            _ctx: &RunContext,
231        ) -> Result<StepResult, MacroRunError> {
232            Ok(StepResult {
233                step_index: 0,
234                success: true,
235                message: format!("Created template: {}", step.template),
236                output_path: Some(PathBuf::from("test.md")),
237            })
238        }
239
240        fn execute_capture(
241            &self,
242            step: &CaptureStep,
243            _ctx: &RunContext,
244        ) -> Result<StepResult, MacroRunError> {
245            Ok(StepResult {
246                step_index: 0,
247                success: true,
248                message: format!("Executed capture: {}", step.capture),
249                output_path: None,
250            })
251        }
252
253        fn execute_shell(
254            &self,
255            step: &ShellStep,
256            _ctx: &RunContext,
257        ) -> Result<StepResult, MacroRunError> {
258            Ok(StepResult {
259                step_index: 0,
260                success: true,
261                message: format!("Executed: {}", step.shell),
262                output_path: None,
263            })
264        }
265    }
266
267    #[test]
268    fn test_run_macro_simple() {
269        let spec = MacroSpec {
270            name: "test".to_string(),
271            description: String::new(),
272            vars: None,
273            steps: vec![MacroStep::Template(TemplateStep {
274                template: "meeting".to_string(),
275                output: None,
276                vars_with: HashMap::new(),
277            })],
278            on_error: ErrorPolicy::Abort,
279        };
280
281        let loaded = LoadedMacro {
282            logical_name: "test".to_string(),
283            path: PathBuf::from("test.yaml"),
284            spec,
285        };
286
287        let ctx = RunContext::new(HashMap::new(), RunOptions::default());
288        let result = run_macro(&loaded, &MockExecutor, ctx);
289
290        assert!(result.success);
291        assert_eq!(result.step_results.len(), 1);
292    }
293
294    #[test]
295    fn test_shell_requires_trust() {
296        let spec = MacroSpec {
297            name: "test".to_string(),
298            description: String::new(),
299            vars: None,
300            steps: vec![MacroStep::Shell(ShellStep {
301                shell: "echo hello".to_string(),
302                description: String::new(),
303            })],
304            on_error: ErrorPolicy::Abort,
305        };
306
307        let loaded = LoadedMacro {
308            logical_name: "test".to_string(),
309            path: PathBuf::from("test.yaml"),
310            spec,
311        };
312
313        // Without trust
314        let ctx = RunContext::new(HashMap::new(), RunOptions::default());
315        let result = run_macro(&loaded, &MockExecutor, ctx);
316        assert!(!result.success);
317
318        // With trust but shell disabled
319        let ctx = RunContext::new(
320            HashMap::new(),
321            RunOptions { trust: true, allow_shell: false, dry_run: false },
322        );
323        let result = run_macro(&loaded, &MockExecutor, ctx);
324        assert!(!result.success);
325
326        // With trust and shell enabled
327        let ctx = RunContext::new(
328            HashMap::new(),
329            RunOptions { trust: true, allow_shell: true, dry_run: false },
330        );
331        let result = run_macro(&loaded, &MockExecutor, ctx);
332        assert!(result.success);
333    }
334
335    #[test]
336    fn test_requires_trust_check() {
337        let spec_with_shell = MacroSpec {
338            name: "test".to_string(),
339            description: String::new(),
340            vars: None,
341            steps: vec![
342                MacroStep::Template(TemplateStep {
343                    template: "meeting".to_string(),
344                    output: None,
345                    vars_with: HashMap::new(),
346                }),
347                MacroStep::Shell(ShellStep {
348                    shell: "git add .".to_string(),
349                    description: String::new(),
350                }),
351            ],
352            on_error: ErrorPolicy::Abort,
353        };
354
355        let spec_without_shell = MacroSpec {
356            name: "test".to_string(),
357            description: String::new(),
358            vars: None,
359            steps: vec![MacroStep::Template(TemplateStep {
360                template: "meeting".to_string(),
361                output: None,
362                vars_with: HashMap::new(),
363            })],
364            on_error: ErrorPolicy::Abort,
365        };
366
367        assert!(requires_trust(&spec_with_shell));
368        assert!(!requires_trust(&spec_without_shell));
369    }
370}