1use crate::model::RunStatus;
2use std::collections::HashMap;
3use std::io::{Read, Write};
4use std::process::{Command, ExitStatus, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7use time::OffsetDateTime;
8use wait_timeout::ChildExt;
9
10#[derive(Debug, Clone)]
11pub struct Request {
12 pub name: String,
13 pub command_preview: String,
14 pub use_shell: bool,
15 pub exec: Vec<String>,
16 pub shell: String,
17 pub dir: String,
18 pub env: HashMap<String, String>,
19 pub timeout: Duration,
20 pub retries: i32,
21 pub retry_backoff: Duration,
22 pub stream_output: bool,
23}
24
25#[derive(Debug, Clone)]
26pub struct RunResult {
27 pub started_at: OffsetDateTime,
28 pub duration: Duration,
29 pub exit_code: i32,
30 pub status: RunStatus,
31 pub stderr_tail: Option<String>,
32}
33
34#[derive(Debug, Clone)]
35pub struct RunFailure {
36 pub result: RunResult,
37 pub message: String,
38}
39
40pub fn execute(req: &Request) -> Result<RunResult, RunFailure> {
41 if req.retries < 0 {
42 return Err(RunFailure {
43 result: failed_result(127, Duration::ZERO, None),
44 message: "retries must be >= 0".to_string(),
45 });
46 }
47
48 if req.use_shell && req.shell.trim().is_empty() {
49 return Err(RunFailure {
50 result: failed_result(127, Duration::ZERO, None),
51 message: "shell command is required".to_string(),
52 });
53 }
54
55 if !req.use_shell && req.exec.is_empty() {
56 return Err(RunFailure {
57 result: failed_result(127, Duration::ZERO, None),
58 message: "exec command is required".to_string(),
59 });
60 }
61
62 let retry_backoff = if req.retry_backoff.is_zero() {
63 Duration::from_secs(1)
64 } else {
65 req.retry_backoff
66 };
67
68 let start = OffsetDateTime::now_utc();
69 let wall = Instant::now();
70 let attempts = req.retries + 1;
71
72 let mut last_exit = 0;
73 let mut last_stderr = None;
74 let mut last_error = String::new();
75
76 for attempt in 0..attempts {
77 match run_once(req) {
78 Ok((code, stderr_tail, None)) => {
79 return Ok(RunResult {
80 started_at: start,
81 duration: wall.elapsed(),
82 exit_code: code,
83 status: RunStatus::Success,
84 stderr_tail,
85 });
86 }
87 Ok((code, stderr_tail, Some(err))) => {
88 last_exit = code;
89 last_stderr = stderr_tail;
90 last_error = err;
91 }
92 Err(err) => {
93 last_exit = 127;
94 last_stderr = None;
95 last_error = err;
96 }
97 }
98
99 if attempt < attempts - 1 {
100 let wait = retry_backoff
101 .checked_mul(1_u32 << attempt)
102 .unwrap_or(Duration::from_secs(60));
103 thread::sleep(wait);
104 }
105 }
106
107 Err(RunFailure {
108 result: RunResult {
109 started_at: start,
110 duration: wall.elapsed(),
111 exit_code: last_exit,
112 status: RunStatus::Failed,
113 stderr_tail: last_stderr,
114 },
115 message: last_error,
116 })
117}
118
119fn run_once(req: &Request) -> Result<(i32, Option<String>, Option<String>), String> {
120 let mut command = build_command(req)?;
121 if !req.dir.is_empty() {
122 command.current_dir(&req.dir);
123 }
124 if !req.env.is_empty() {
125 command.envs(&req.env);
126 }
127
128 if req.stream_output {
129 command.stdout(Stdio::inherit());
130 } else {
131 command.stdout(Stdio::null());
132 }
133 command.stderr(Stdio::piped());
134
135 let mut child = command.spawn().map_err(|e| format!("run command: {e}"))?;
136
137 let stderr = child
138 .stderr
139 .take()
140 .ok_or_else(|| "failed to capture stderr".to_string())?;
141
142 let stream_output = req.stream_output;
143 let stderr_handle = thread::spawn(move || {
144 let mut reader = std::io::BufReader::new(stderr);
145 let mut buf = [0_u8; 4096];
146 let mut all = Vec::new();
147 let mut sink = std::io::stderr().lock();
148
149 loop {
150 let read = match reader.read(&mut buf) {
151 Ok(0) => break,
152 Ok(n) => n,
153 Err(_) => break,
154 };
155
156 let chunk = &buf[..read];
157 if stream_output {
158 let _ = sink.write_all(chunk);
159 let _ = sink.flush();
160 }
161 all.extend_from_slice(chunk);
162 }
163
164 all
165 });
166
167 let (status, timeout_hit) = wait_child(&mut child, req.timeout)?;
168 let stderr_bytes = stderr_handle
169 .join()
170 .map_err(|_| "stderr reader thread panicked".to_string())?;
171
172 let stderr_text = String::from_utf8_lossy(&stderr_bytes).to_string();
173 let stderr_tail = tail(&stderr_text, 10, 1400);
174
175 if timeout_hit {
176 return Ok((
177 124,
178 stderr_tail,
179 Some(format!(
180 "command timed out after {}",
181 format_duration(req.timeout)
182 )),
183 ));
184 }
185
186 if status.success() {
187 return Ok((0, stderr_tail, None));
188 }
189
190 let code = status.code().unwrap_or(1);
191 Ok((
192 code,
193 stderr_tail,
194 Some(format!("command failed with exit code {code}")),
195 ))
196}
197
198fn wait_child(
199 child: &mut std::process::Child,
200 timeout: Duration,
201) -> Result<(ExitStatus, bool), String> {
202 if timeout.is_zero() {
203 let status = child.wait().map_err(|e| format!("wait command: {e}"))?;
204 return Ok((status, false));
205 }
206
207 match child
208 .wait_timeout(timeout)
209 .map_err(|e| format!("wait command: {e}"))?
210 {
211 Some(status) => Ok((status, false)),
212 None => {
213 let _ = child.kill();
214 let status = child.wait().map_err(|e| format!("wait command: {e}"))?;
215 Ok((status, true))
216 }
217 }
218}
219
220fn build_command(req: &Request) -> Result<Command, String> {
221 if req.use_shell {
222 if cfg!(target_os = "windows") {
223 let mut cmd = Command::new("cmd");
224 cmd.arg("/C").arg(&req.shell);
225 return Ok(cmd);
226 }
227
228 let mut cmd = Command::new("/bin/sh");
229 cmd.arg("-c").arg(&req.shell);
230 return Ok(cmd);
231 }
232
233 let Some(program) = req.exec.first() else {
234 return Err("exec command is required".to_string());
235 };
236
237 let mut cmd = Command::new(program);
238 if req.exec.len() > 1 {
239 cmd.args(&req.exec[1..]);
240 }
241 Ok(cmd)
242}
243
244fn failed_result(exit_code: i32, duration: Duration, stderr_tail: Option<String>) -> RunResult {
245 RunResult {
246 started_at: OffsetDateTime::now_utc(),
247 duration,
248 exit_code,
249 status: RunStatus::Failed,
250 stderr_tail,
251 }
252}
253
254pub fn tail(input: &str, line_limit: usize, char_limit: usize) -> Option<String> {
255 if input.is_empty() {
256 return None;
257 }
258
259 let trimmed = input.trim_end_matches('\n');
260 if trimmed.is_empty() {
261 return None;
262 }
263
264 let mut lines: Vec<&str> = trimmed.lines().collect();
265 if lines.len() > line_limit {
266 lines = lines.split_off(lines.len() - line_limit);
267 }
268
269 let mut out = lines.join("\n");
270
271 if out.chars().count() > char_limit {
272 let start = out.chars().count().saturating_sub(char_limit);
273 out = out.chars().skip(start).collect();
274 }
275
276 Some(out)
277}
278
279fn format_duration(duration: Duration) -> String {
280 let ms = duration.as_millis();
281
282 if ms < 1_000 {
283 return format!("{ms}ms");
284 }
285
286 if ms.is_multiple_of(1_000) {
287 return format!("{}s", ms / 1_000);
288 }
289
290 format!("{:.3}s", duration.as_secs_f64())
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::model::RunStatus;
297 use tempfile::tempdir;
298
299 fn base_request() -> Request {
300 Request {
301 name: "inline".to_string(),
302 command_preview: "echo ok".to_string(),
303 use_shell: false,
304 exec: vec![
305 "/bin/sh".to_string(),
306 "-c".to_string(),
307 "echo ok".to_string(),
308 ],
309 shell: String::new(),
310 dir: String::new(),
311 env: HashMap::new(),
312 timeout: Duration::ZERO,
313 retries: 0,
314 retry_backoff: Duration::from_millis(10),
315 stream_output: true,
316 }
317 }
318
319 #[test]
320 fn execute_success() {
321 let result = execute(&base_request()).expect("success");
322 assert_eq!(result.exit_code, 0);
323 assert_eq!(result.status, RunStatus::Success);
324 }
325
326 #[test]
327 fn execute_failure() {
328 let mut req = base_request();
329 req.exec = vec![
330 "/bin/sh".to_string(),
331 "-c".to_string(),
332 "echo err >&2; exit 7".to_string(),
333 ];
334
335 let err = execute(&req).expect_err("expected failure");
336 assert_eq!(err.result.exit_code, 7);
337 assert_eq!(err.result.status, RunStatus::Failed);
338 }
339
340 #[test]
341 fn execute_timeout() {
342 let mut req = base_request();
343 req.exec = vec![
344 "/bin/sh".to_string(),
345 "-c".to_string(),
346 "sleep 1".to_string(),
347 ];
348 req.timeout = Duration::from_millis(50);
349
350 let err = execute(&req).expect_err("expected timeout");
351 assert_eq!(err.result.exit_code, 124);
352 }
353
354 #[test]
355 fn execute_retry_then_success() {
356 let dir = tempdir().expect("tempdir");
357 let flag = dir.path().join("flag");
358 let script = format!(
359 r#"[ -f "{}" ] || {{ touch "{}"; exit 9; }}; exit 0"#,
360 flag.display(),
361 flag.display()
362 );
363
364 let mut req = base_request();
365 req.use_shell = true;
366 req.exec.clear();
367 req.shell = script;
368 req.retries = 1;
369
370 let result = execute(&req).expect("retry succeeds");
371 assert_eq!(result.exit_code, 0);
372 }
373
374 #[test]
375 fn validate_request_retries() {
376 let mut req = base_request();
377 req.retries = -1;
378 assert!(execute(&req).is_err());
379 }
380
381 #[test]
382 fn tail_limits_output() {
383 let input = "a\nb\nc\nd\ne\nf";
384 let out = tail(input, 3, 10).expect("tail");
385 assert_eq!(out, "d\ne\nf");
386 }
387}