1use crate::error::{Error, Result};
2use std::collections::HashMap;
3use std::fs::File;
4use std::io::{self, Read, Write};
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7use std::thread;
8use std::time::Duration;
9use wait_timeout::ChildExt;
10
11pub struct CmdTool;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CmdStdin {
15 Text(String),
16 Bytes(Vec<u8>),
17 File(PathBuf),
18 Null,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct CmdRequest {
23 pub program: String,
24 pub args: Vec<String>,
25 pub cwd: Option<String>,
26 pub env: Option<HashMap<String, String>>,
27 pub timeout_ms: Option<u64>,
28 pub fail_on_non_zero: bool,
29 pub stdin: Option<CmdStdin>,
30 pub background: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ShellCmdRequest {
35 pub command: String,
36 pub cwd: Option<String>,
37 pub env: Option<HashMap<String, String>>,
38 pub timeout_ms: Option<u64>,
39 pub fail_on_non_zero: bool,
40 pub stdin: Option<CmdStdin>,
41 pub background: bool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CmdOutput {
46 pub stdout: String,
47 pub stderr: String,
48 pub exit_code: i32,
49 pub pid: Option<u32>,
50}
51
52impl CmdTool {
53 pub fn run(req: CmdRequest) -> Result<CmdOutput> {
54 let mut cmd = Command::new(&req.program);
55 cmd.args(&req.args);
56
57 run_inner(
58 &mut cmd,
59 req.cwd,
60 req.env,
61 req.timeout_ms,
62 req.fail_on_non_zero,
63 req.stdin,
64 req.background,
65 )
66 }
67
68 pub fn run_shell(req: ShellCmdRequest) -> Result<CmdOutput> {
69 let mut cmd = build_shell_command(&req.command);
70
71 run_inner(
72 &mut cmd,
73 req.cwd,
74 req.env,
75 req.timeout_ms,
76 req.fail_on_non_zero,
77 req.stdin,
78 req.background,
79 )
80 }
81}
82
83fn run_inner(
84 cmd: &mut Command,
85 cwd: Option<String>,
86 env: Option<HashMap<String, String>>,
87 timeout_ms: Option<u64>,
88 fail_on_non_zero: bool,
89 stdin: Option<CmdStdin>,
90 background: bool,
91) -> Result<CmdOutput> {
92 configure_command(cmd, cwd, env, stdin.as_ref(), background)?;
93 let mut child = spawn_child(cmd)?;
94 let stdout_handle = take_output_reader(&mut child.stdout);
95 let stderr_handle = take_output_reader(&mut child.stderr);
96
97 write_stdin(&mut child, stdin.as_ref())?;
98
99 if background {
100 return Ok(background_output(&child));
101 }
102
103 let status = match wait_for_child(&mut child, timeout_ms) {
104 Ok(status) => status,
105 Err(err) => {
106 let _ = collect_output(stdout_handle);
107 let _ = collect_output(stderr_handle);
108 return Err(err);
109 }
110 };
111
112 build_foreground_output(status, stdout_handle, stderr_handle, fail_on_non_zero)
113}
114
115fn configure_command(
116 cmd: &mut Command,
117 cwd: Option<String>,
118 env: Option<HashMap<String, String>>,
119 stdin: Option<&CmdStdin>,
120 background: bool,
121) -> Result<()> {
122 if let Some(cwd) = cwd {
123 cmd.current_dir(cwd);
124 }
125
126 if let Some(env) = env {
127 cmd.envs(env);
128 }
129
130 configure_stdin(cmd, stdin, background)?;
131 configure_output(cmd, background);
132 Ok(())
133}
134
135fn configure_stdin(cmd: &mut Command, stdin: Option<&CmdStdin>, background: bool) -> Result<()> {
136 match stdin {
137 Some(CmdStdin::File(path)) => {
138 let file = File::open(path).map_err(Error::tool_io)?;
139 cmd.stdin(file);
140 }
141 Some(CmdStdin::Null) => {
142 cmd.stdin(Stdio::null());
143 }
144 Some(_) => {
145 cmd.stdin(Stdio::piped());
146 }
147 None if background => {
148 cmd.stdin(Stdio::null());
149 }
150 None => {}
151 }
152
153 Ok(())
154}
155
156fn configure_output(cmd: &mut Command, background: bool) {
157 if background {
158 cmd.stdout(Stdio::null());
159 cmd.stderr(Stdio::null());
160 return;
161 }
162
163 cmd.stdout(Stdio::piped());
164 cmd.stderr(Stdio::piped());
165}
166
167fn spawn_child(cmd: &mut Command) -> Result<std::process::Child> {
168 cmd.spawn().map_err(Error::tool_io)
169}
170
171fn take_output_reader<R>(pipe: &mut Option<R>) -> Option<thread::JoinHandle<io::Result<Vec<u8>>>>
172where
173 R: Read + Send + 'static,
174{
175 pipe.take().map(spawn_reader)
176}
177
178fn background_output(child: &std::process::Child) -> CmdOutput {
179 CmdOutput {
180 stdout: String::new(),
181 stderr: String::new(),
182 exit_code: 0,
183 pid: Some(child.id()),
184 }
185}
186
187fn wait_for_child(
188 child: &mut std::process::Child,
189 timeout_ms: Option<u64>,
190) -> Result<std::process::ExitStatus> {
191 match timeout_ms {
192 Some(timeout_ms) => wait_with_timeout(child, timeout_ms),
193 None => child.wait().map_err(Error::tool_io),
194 }
195}
196
197fn wait_with_timeout(
198 child: &mut std::process::Child,
199 timeout_ms: u64,
200) -> Result<std::process::ExitStatus> {
201 let duration = Duration::from_millis(timeout_ms);
202 match child.wait_timeout(duration).map_err(Error::tool_io)? {
203 Some(status) => Ok(status),
204 None => {
205 kill_timed_out_child(child)?;
206 Err(Error::tool_timeout())
207 }
208 }
209}
210
211fn kill_timed_out_child(child: &mut std::process::Child) -> Result<()> {
212 child.kill().map_err(Error::tool_io)?;
213 child.wait().map_err(Error::tool_io)?;
214 Ok(())
215}
216
217fn write_stdin(child: &mut std::process::Child, stdin_content: Option<&CmdStdin>) -> Result<()> {
218 let Some(stdin_content) = stdin_content else {
219 return Ok(());
220 };
221
222 let Some(mut stdin) = child.stdin.take() else {
223 if matches!(stdin_content, CmdStdin::Text(_) | CmdStdin::Bytes(_)) {
224 return Err(Error::tool_io(io::Error::new(
225 io::ErrorKind::BrokenPipe,
226 "stdin pipe not available",
227 )));
228 }
229 return Ok(());
230 };
231
232 match stdin_content {
233 CmdStdin::Text(text) => stdin.write_all(text.as_bytes()).map_err(Error::tool_io)?,
234 CmdStdin::Bytes(bytes) => stdin.write_all(bytes).map_err(Error::tool_io)?,
235 CmdStdin::File(_) | CmdStdin::Null => {}
236 }
237
238 stdin.flush().map_err(Error::tool_io)?;
239 drop(stdin);
240 Ok(())
241}
242
243fn spawn_reader<R>(mut reader: R) -> thread::JoinHandle<io::Result<Vec<u8>>>
244where
245 R: Read + Send + 'static,
246{
247 thread::spawn(move || {
248 let mut buf = Vec::new();
249 reader.read_to_end(&mut buf)?;
250 Ok(buf)
251 })
252}
253
254fn collect_output(handle: Option<thread::JoinHandle<io::Result<Vec<u8>>>>) -> Result<String> {
255 let Some(handle) = handle else {
256 return Ok(String::new());
257 };
258
259 let bytes = handle
260 .join()
261 .map_err(|_| Error::tool_io(io::Error::other("output reader thread panicked")))?
262 .map_err(Error::tool_io)?;
263
264 Ok(String::from_utf8_lossy(&bytes).into_owned())
265}
266
267fn build_foreground_output(
268 status: std::process::ExitStatus,
269 stdout_handle: Option<thread::JoinHandle<io::Result<Vec<u8>>>>,
270 stderr_handle: Option<thread::JoinHandle<io::Result<Vec<u8>>>>,
271 fail_on_non_zero: bool,
272) -> Result<CmdOutput> {
273 let stdout = collect_output(stdout_handle)?;
274 let stderr = collect_output(stderr_handle)?;
275 let exit_code = status.code().unwrap_or(-1);
276
277 if fail_on_non_zero && exit_code != 0 {
278 return Err(Error::tool_cmd_failed(exit_code));
279 }
280
281 Ok(CmdOutput {
282 stdout,
283 stderr,
284 exit_code,
285 pid: None,
286 })
287}
288
289fn build_shell_command(command: &str) -> Command {
290 if cfg!(target_os = "windows") {
291 let mut cmd = Command::new("cmd.exe");
292 cmd.arg("/c").arg(command);
293 cmd
294 } else {
295 let mut cmd = Command::new("sh");
296 cmd.arg("-c").arg(command);
297 cmd
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::io::Write;
305 use tempfile::NamedTempFile;
306
307 #[test]
308 fn test_successful_command() {
309 let req = CmdRequest {
310 program: "echo".to_string(),
311 args: vec!["hello".to_string()],
312 cwd: None,
313 env: None,
314 timeout_ms: None,
315 fail_on_non_zero: false,
316 stdin: None,
317 background: false,
318 };
319 let result = CmdTool::run(req);
320 assert!(result.is_ok());
321 let output = result.unwrap();
322 assert_eq!(output.exit_code, 0);
323 assert_eq!(output.stdout.trim(), "hello");
324 assert!(output.pid.is_none());
325 }
326
327 #[test]
328 fn test_shell_command() {
329 let req = ShellCmdRequest {
330 command: "echo 'hello from shell'".to_string(),
331 cwd: None,
332 env: None,
333 timeout_ms: None,
334 fail_on_non_zero: false,
335 stdin: None,
336 background: false,
337 };
338 let result = CmdTool::run_shell(req);
339 assert!(result.is_ok());
340 let output = result.unwrap();
341 assert_eq!(output.exit_code, 0);
342 assert_eq!(output.stdout.trim(), "hello from shell");
343 assert!(output.pid.is_none());
344 }
345
346 #[test]
347 fn test_timeout_command() {
348 let req = CmdRequest {
349 program: "sleep".to_string(),
350 args: vec!["2".to_string()],
351 cwd: None,
352 env: None,
353 timeout_ms: Some(100),
354 fail_on_non_zero: false,
355 stdin: None,
356 background: false,
357 };
358 let result = CmdTool::run(req);
359 assert!(result.is_err());
360 let err_msg = result.unwrap_err().to_string().to_lowercase();
361 assert!(err_msg.contains("timed out"));
362 }
363
364 #[test]
365 fn test_non_existent_command() {
366 let req = CmdRequest {
367 program: "this_command_does_not_exist_12345".to_string(),
368 args: vec![],
369 cwd: None,
370 env: None,
371 timeout_ms: None,
372 fail_on_non_zero: false,
373 stdin: None,
374 background: false,
375 };
376 let result = CmdTool::run(req);
377 assert!(result.is_err());
378 let err_msg = result.unwrap_err().to_string().to_lowercase();
379 assert!(err_msg.contains("no such file") || err_msg.contains("not found"));
380 }
381
382 #[test]
383 fn test_stdin_text() {
384 let req = CmdRequest {
385 program: "cat".to_string(),
386 args: vec![],
387 cwd: None,
388 env: None,
389 timeout_ms: None,
390 fail_on_non_zero: false,
391 stdin: Some(CmdStdin::Text("hello stdin text".to_string())),
392 background: false,
393 };
394 let result = CmdTool::run(req);
395 assert!(result.is_ok());
396 let output = result.unwrap();
397 assert_eq!(output.exit_code, 0);
398 assert_eq!(output.stdout, "hello stdin text");
399 assert!(output.pid.is_none());
400 }
401
402 #[test]
403 fn test_stdin_bytes() {
404 let req = CmdRequest {
405 program: "cat".to_string(),
406 args: vec![],
407 cwd: None,
408 env: None,
409 timeout_ms: None,
410 fail_on_non_zero: false,
411 stdin: Some(CmdStdin::Bytes(b"hello stdin bytes".to_vec())),
412 background: false,
413 };
414 let result = CmdTool::run(req);
415 assert!(result.is_ok());
416 let output = result.unwrap();
417 assert_eq!(output.exit_code, 0);
418 assert_eq!(output.stdout, "hello stdin bytes");
419 assert!(output.pid.is_none());
420 }
421
422 #[test]
423 fn test_stdin_file() {
424 let mut temp_file = NamedTempFile::new().unwrap();
425 write!(temp_file, "hello stdin file").unwrap();
426
427 let req = CmdRequest {
428 program: "cat".to_string(),
429 args: vec![],
430 cwd: None,
431 env: None,
432 timeout_ms: None,
433 fail_on_non_zero: false,
434 stdin: Some(CmdStdin::File(temp_file.path().to_path_buf())),
435 background: false,
436 };
437 let result = CmdTool::run(req);
438 assert!(result.is_ok());
439 let output = result.unwrap();
440 assert_eq!(output.exit_code, 0);
441 assert_eq!(output.stdout, "hello stdin file");
442 assert!(output.pid.is_none());
443 }
444
445 #[test]
446 fn test_stdin_null() {
447 let req = CmdRequest {
448 program: "cat".to_string(),
449 args: vec![],
450 cwd: None,
451 env: None,
452 timeout_ms: Some(1_000),
453 fail_on_non_zero: false,
454 stdin: Some(CmdStdin::Null),
455 background: false,
456 };
457 let result = CmdTool::run(req);
458 assert!(result.is_ok());
459 let output = result.unwrap();
460 assert_eq!(output.exit_code, 0);
461 assert!(output.stdout.is_empty());
462 assert!(output.pid.is_none());
463 }
464
465 #[test]
466 fn test_background() {
467 let req = CmdRequest {
468 program: "sleep".to_string(),
469 args: vec!["1".to_string()],
470 cwd: None,
471 env: None,
472 timeout_ms: None,
473 fail_on_non_zero: false,
474 stdin: None,
475 background: true,
476 };
477 let result = CmdTool::run(req);
478 assert!(result.is_ok());
479 let output = result.unwrap();
480 assert_eq!(output.exit_code, 0);
481 assert!(output.stdout.is_empty());
482 assert!(output.pid.is_some());
483 assert!(output.pid.unwrap() > 0);
484 }
485
486 #[test]
487 fn test_shell_pipe() {
488 let command = if cfg!(target_os = "windows") {
489 "echo hello pipe | findstr pipe"
490 } else {
491 "echo 'hello pipe' | grep pipe"
492 };
493
494 let req = ShellCmdRequest {
495 command: command.to_string(),
496 cwd: None,
497 env: None,
498 timeout_ms: None,
499 fail_on_non_zero: false,
500 stdin: None,
501 background: false,
502 };
503 let result = CmdTool::run_shell(req);
504 assert!(result.is_ok());
505 let output = result.unwrap();
506 assert_eq!(output.exit_code, 0);
507 assert!(output.stdout.contains("hello pipe"));
508 assert!(output.pid.is_none());
509 }
510
511 #[test]
512 fn test_non_zero_exit_can_fail() {
513 let req = if cfg!(target_os = "windows") {
514 ShellCmdRequest {
515 command: "cmd /c exit 7".to_string(),
516 cwd: None,
517 env: None,
518 timeout_ms: None,
519 fail_on_non_zero: true,
520 stdin: None,
521 background: false,
522 }
523 } else {
524 ShellCmdRequest {
525 command: "sh -c 'exit 7'".to_string(),
526 cwd: None,
527 env: None,
528 timeout_ms: None,
529 fail_on_non_zero: true,
530 stdin: None,
531 background: false,
532 }
533 };
534
535 let result = CmdTool::run_shell(req);
536 assert!(result.is_err());
537 let err_msg = result.unwrap_err().to_string().to_lowercase();
538 assert!(err_msg.contains("exit code 7"));
539 }
540
541 #[test]
542 fn test_non_zero_exit_can_be_observed_without_error() {
543 let req = if cfg!(target_os = "windows") {
544 ShellCmdRequest {
545 command: "cmd /c exit 9".to_string(),
546 cwd: None,
547 env: None,
548 timeout_ms: None,
549 fail_on_non_zero: false,
550 stdin: None,
551 background: false,
552 }
553 } else {
554 ShellCmdRequest {
555 command: "sh -c 'exit 9'".to_string(),
556 cwd: None,
557 env: None,
558 timeout_ms: None,
559 fail_on_non_zero: false,
560 stdin: None,
561 background: false,
562 }
563 };
564
565 let result = CmdTool::run_shell(req).unwrap();
566 assert_eq!(result.exit_code, 9);
567 }
568
569 #[cfg(not(target_os = "windows"))]
570 #[test]
571 fn test_non_utf8_stdout_is_preserved_lossily() {
572 let req = ShellCmdRequest {
573 command: "printf '\\377\\376abc'".to_string(),
574 cwd: None,
575 env: None,
576 timeout_ms: None,
577 fail_on_non_zero: false,
578 stdin: None,
579 background: false,
580 };
581
582 let result = CmdTool::run_shell(req).unwrap();
583 assert!(result.stdout.contains("abc"));
584 assert!(!result.stdout.is_empty());
585 }
586}