Skip to main content

minion_engine/steps/
gate.rs

1use async_trait::async_trait;
2
3use crate::config::StepConfig;
4use crate::control_flow::ControlFlow;
5use crate::engine::context::Context;
6use crate::error::StepError;
7use crate::workflow::schema::StepDef;
8
9use super::{GateOutput, StepExecutor, StepOutput};
10
11pub struct GateExecutor;
12
13#[async_trait]
14impl StepExecutor for GateExecutor {
15    async fn execute(
16        &self,
17        step: &StepDef,
18        _config: &StepConfig,
19        ctx: &Context,
20    ) -> Result<StepOutput, StepError> {
21        let condition_template = step
22            .condition
23            .as_ref()
24            .ok_or_else(|| StepError::Fail("gate step missing 'condition' field".into()))?;
25
26        let rendered = ctx.render_template(condition_template)?;
27        let passed = evaluate_bool(&rendered);
28        let message = step.message.clone();
29
30        let on_pass = step.on_pass.as_deref().unwrap_or("continue");
31        let on_fail = step.on_fail.as_deref().unwrap_or("continue");
32
33        let action = if passed { on_pass } else { on_fail };
34
35        match action {
36            "break" => Err(ControlFlow::Break {
37                message: message.unwrap_or_else(|| "gate break".into()),
38                value: None,
39            }
40            .into()),
41            "fail" => Err(ControlFlow::Fail {
42                message: message.unwrap_or_else(|| "gate failed".into()),
43            }
44            .into()),
45            "skip" | "skip_next" => Err(ControlFlow::Skip {
46                message: message.unwrap_or_else(|| "gate skip".into()),
47            }
48            .into()),
49            _ => {
50                // "continue" or unknown → just return the gate output
51                Ok(StepOutput::Gate(GateOutput { passed, message }))
52            }
53        }
54    }
55}
56
57fn evaluate_bool(s: &str) -> bool {
58    let trimmed = s.trim().to_lowercase();
59    matches!(trimmed.as_str(), "true" | "1" | "yes" | "ok")
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::config::StepConfig;
66    use crate::engine::context::Context;
67    use crate::steps::{CmdOutput, StepOutput};
68    use crate::workflow::schema::StepType;
69    use std::collections::HashMap;
70    use std::time::Duration;
71
72    fn gate_step(condition: &str) -> StepDef {
73        StepDef {
74            name: "check".to_string(),
75            step_type: StepType::Gate,
76            run: None,
77            prompt: None,
78            condition: Some(condition.to_string()),
79            on_pass: None,
80            on_fail: None,
81            message: None,
82            scope: None,
83            max_iterations: None,
84            initial_value: None,
85            items: None,
86            parallel: None,
87            steps: None,
88            config: HashMap::new(),
89            outputs: None,
90            output_type: None,
91            async_exec: None,
92        }
93    }
94
95    #[test]
96    fn bool_evaluation() {
97        assert!(evaluate_bool("true"));
98        assert!(evaluate_bool("  True  "));
99        assert!(evaluate_bool("1"));
100        assert!(evaluate_bool("yes"));
101        assert!(!evaluate_bool("false"));
102        assert!(!evaluate_bool("0"));
103        assert!(!evaluate_bool("no"));
104        assert!(!evaluate_bool(""));
105    }
106
107    #[tokio::test]
108    async fn gate_condition_references_previous_step_exit_code() {
109        let mut ctx = Context::new(String::new(), HashMap::new());
110        ctx.store(
111            "prev_step",
112            StepOutput::Cmd(CmdOutput {
113                stdout: "output".to_string(),
114                stderr: String::new(),
115                exit_code: 0,
116                duration: Duration::ZERO,
117            }),
118        );
119
120        // Condition references previous step's exit_code via template
121        let step = gate_step("{{ steps.prev_step.exit_code == 0 }}");
122        let result = GateExecutor
123            .execute(&step, &StepConfig::default(), &ctx)
124            .await
125            .unwrap();
126
127        if let StepOutput::Gate(gate) = result {
128            assert!(gate.passed, "Gate should pass when exit_code == 0");
129        } else {
130            panic!("Expected Gate output");
131        }
132    }
133
134    #[tokio::test]
135    async fn gate_condition_fails_when_exit_code_nonzero() {
136        let mut ctx = Context::new(String::new(), HashMap::new());
137        ctx.store(
138            "cmd_step",
139            StepOutput::Cmd(CmdOutput {
140                stdout: String::new(),
141                stderr: "error".to_string(),
142                exit_code: 1,
143                duration: Duration::ZERO,
144            }),
145        );
146
147        let step = gate_step("{{ steps.cmd_step.exit_code == 0 }}");
148        let result = GateExecutor
149            .execute(&step, &StepConfig::default(), &ctx)
150            .await
151            .unwrap();
152
153        if let StepOutput::Gate(gate) = result {
154            assert!(!gate.passed, "Gate should fail when exit_code != 0");
155        } else {
156            panic!("Expected Gate output");
157        }
158    }
159}