1use std::time::{Duration, Instant};
2
3use async_trait::async_trait;
4use tokio::process::Command;
5
6use crate::config::StepConfig;
7use crate::engine::context::Context;
8use crate::error::StepError;
9use crate::workflow::schema::StepDef;
10
11use super::{CmdOutput, SandboxAwareExecutor, SharedSandbox, StepExecutor, StepOutput};
12
13pub struct CmdExecutor;
14
15#[async_trait]
16impl StepExecutor for CmdExecutor {
17 async fn execute(
18 &self,
19 step: &StepDef,
20 config: &StepConfig,
21 ctx: &Context,
22 ) -> Result<StepOutput, StepError> {
23 self.execute_sandboxed(step, config, ctx, &None).await
24 }
25}
26
27#[async_trait]
28impl SandboxAwareExecutor for CmdExecutor {
29 async fn execute_sandboxed(
30 &self,
31 step: &StepDef,
32 config: &StepConfig,
33 ctx: &Context,
34 sandbox: &SharedSandbox,
35 ) -> Result<StepOutput, StepError> {
36 let run_template = step
37 .run
38 .as_ref()
39 .ok_or_else(|| StepError::Fail("cmd step missing 'run' field".into()))?;
40
41 let command = ctx.render_template(run_template)?;
42 let timeout = config
43 .get_duration("timeout")
44 .unwrap_or(Duration::from_secs(60));
45 let fail_on_error = config.get_bool("fail_on_error");
46
47 let start = Instant::now();
48
49 let result = if let Some(sb) = sandbox {
51 let sb_guard = sb.lock().await;
52 let sb_output = tokio::time::timeout(timeout, sb_guard.run_command(&command))
53 .await
54 .map_err(|_| StepError::Timeout(timeout))?
55 .map_err(|e| StepError::Fail(format!("Sandbox command failed: {e}")))?;
56
57 CmdOutput {
58 stdout: sb_output.stdout,
59 stderr: sb_output.stderr,
60 exit_code: sb_output.exit_code,
61 duration: start.elapsed(),
62 }
63 } else {
64 let shell = config.get_str("shell").unwrap_or("/bin/bash");
66 let working_dir = config.get_str("working_directory").map(String::from);
67
68 let mut cmd = Command::new(shell);
69 cmd.arg("-c").arg(&command);
70 cmd.stdout(std::process::Stdio::piped());
71 cmd.stderr(std::process::Stdio::piped());
72
73 let current_path = std::env::var("PATH").unwrap_or_default();
75 let extra_dirs = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/local/sbin"];
76 let mut full_path = current_path.clone();
77 for dir in &extra_dirs {
78 if !current_path.contains(dir) {
79 full_path = format!("{}:{}", dir, full_path);
80 }
81 }
82 if let Ok(home) = std::env::var("HOME") {
84 let cargo_bin = format!("{}/.cargo/bin", home);
85 if !full_path.contains(&cargo_bin) {
86 full_path = format!("{}:{}", cargo_bin, full_path);
87 }
88 }
89 cmd.env("PATH", &full_path);
90
91 if let Some(dir) = &working_dir {
92 cmd.current_dir(dir);
93 }
94
95 let output = tokio::time::timeout(timeout, cmd.output())
96 .await
97 .map_err(|_| StepError::Timeout(timeout))?
98 .map_err(|e| StepError::Fail(format!("Failed to spawn command: {e}")))?;
99
100 CmdOutput {
101 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
102 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
103 exit_code: output.status.code().unwrap_or(-1),
104 duration: start.elapsed(),
105 }
106 };
107
108 if fail_on_error && result.exit_code != 0 {
109 return Err(StepError::Fail(format!(
110 "Command failed (exit {}): {}",
111 result.exit_code,
112 result.stderr.trim()
113 )));
114 }
115
116 Ok(StepOutput::Cmd(result))
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use std::collections::HashMap;
124
125 fn empty_step(run: &str) -> StepDef {
126 StepDef {
127 name: "test".to_string(),
128 step_type: crate::workflow::schema::StepType::Cmd,
129 run: Some(run.to_string()),
130 prompt: None,
131 condition: None,
132 on_pass: None,
133 on_fail: None,
134 message: None,
135 scope: None,
136 max_iterations: None,
137 initial_value: None,
138 items: None,
139 parallel: None,
140 steps: None,
141 config: HashMap::new(),
142 outputs: None,
143 output_type: None,
144 async_exec: None,
145 }
146 }
147
148 #[tokio::test]
149 async fn cmd_echo() {
150 let step = empty_step("echo hello");
151 let config = StepConfig::default();
152 let ctx = Context::new(String::new(), HashMap::new());
153
154 let result = CmdExecutor.execute(&step, &config, &ctx).await.unwrap();
155 assert_eq!(result.text().trim(), "hello");
156 assert_eq!(result.exit_code(), 0);
157 }
158
159 #[tokio::test]
160 async fn cmd_echo_via_sandbox_aware_no_sandbox() {
161 let step = empty_step("echo sandbox_test");
163 let config = StepConfig::default();
164 let ctx = Context::new(String::new(), HashMap::new());
165
166 let result = CmdExecutor
167 .execute_sandboxed(&step, &config, &ctx, &None)
168 .await
169 .unwrap();
170 assert_eq!(result.text().trim(), "sandbox_test");
171 }
172
173 #[tokio::test]
174 async fn cmd_exit_nonzero_without_fail_on_error() {
175 let step = empty_step("exit 42");
176 let config = StepConfig::default();
177 let ctx = Context::new(String::new(), HashMap::new());
178
179 let result = CmdExecutor.execute(&step, &config, &ctx).await.unwrap();
180 assert_eq!(result.exit_code(), 42);
181 }
182
183 #[tokio::test]
184 async fn cmd_exit_nonzero_with_fail_on_error() {
185 let step = empty_step("exit 1");
186 let mut values = HashMap::new();
187 values.insert(
188 "fail_on_error".to_string(),
189 serde_json::Value::Bool(true),
190 );
191 let config = StepConfig { values };
192 let ctx = Context::new(String::new(), HashMap::new());
193
194 let result = CmdExecutor.execute(&step, &config, &ctx).await;
195 assert!(result.is_err());
196 }
197
198 #[tokio::test]
199 async fn cmd_timeout() {
200 let step = empty_step("sleep 10");
201 let mut values = HashMap::new();
202 values.insert(
203 "timeout".to_string(),
204 serde_json::Value::String("100ms".to_string()),
205 );
206 let config = StepConfig { values };
207 let ctx = Context::new(String::new(), HashMap::new());
208
209 let result = CmdExecutor.execute(&step, &config, &ctx).await;
210 assert!(matches!(result, Err(crate::error::StepError::Timeout(_))));
211 }
212
213 #[tokio::test]
214 async fn cmd_working_directory() {
215 let step = empty_step("pwd");
216 let mut values = HashMap::new();
217 values.insert(
218 "working_directory".to_string(),
219 serde_json::Value::String("/tmp".to_string()),
220 );
221 let config = StepConfig { values };
222 let ctx = Context::new(String::new(), HashMap::new());
223
224 let result = CmdExecutor.execute(&step, &config, &ctx).await.unwrap();
225 assert!(result.text().contains("tmp"));
227 }
228}