minion_engine/steps/
gate.rs1use 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 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 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}