qubit_command/command_runner.rs
1/*******************************************************************************
2 *
3 * Copyright (c) 2026.
4 * Haixing Hu, Qubit Co. Ltd.
5 *
6 * All rights reserved.
7 *
8 ******************************************************************************/
9use std::{
10 io::{
11 self,
12 Read,
13 },
14 path::{
15 Path,
16 PathBuf,
17 },
18 process::{
19 ChildStderr,
20 ChildStdout,
21 Command as ProcessCommand,
22 Stdio,
23 },
24 thread,
25 time::{
26 Duration,
27 Instant,
28 },
29};
30
31use crate::{
32 Command,
33 CommandError,
34 CommandOutput,
35 OutputStream,
36};
37
38/// Default command execution timeout.
39pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
40
41/// Polling interval used while waiting for a child process with timeout.
42const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(10);
43
44/// Runs external commands and captures their output.
45///
46/// `CommandRunner` runs one [`Command`] synchronously on the caller thread and
47/// returns captured process output. The runner always preserves raw output
48/// bytes. Its lossy-output option controls whether [`CommandOutput::stdout`]
49/// and [`CommandOutput::stderr`] reject invalid UTF-8 or return replacement
50/// characters.
51///
52/// # Author
53///
54/// Haixing Hu
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct CommandRunner {
57 /// Maximum duration allowed for each command.
58 timeout: Option<Duration>,
59 /// Default working directory used when a command does not override it.
60 working_directory: Option<PathBuf>,
61 /// Exit codes treated as successful.
62 success_exit_codes: Vec<i32>,
63 /// Whether command execution logs are disabled.
64 disable_logging: bool,
65 /// Whether captured text accessors should replace invalid UTF-8 bytes.
66 lossy_output: bool,
67}
68
69impl Default for CommandRunner {
70 /// Creates a command runner with the default timeout and exit-code policy.
71 ///
72 /// # Returns
73 ///
74 /// A runner with a 10-second timeout, inherited working directory, success
75 /// exit code `0`, and strict UTF-8 output text accessors.
76 #[inline]
77 fn default() -> Self {
78 Self {
79 timeout: Some(DEFAULT_COMMAND_TIMEOUT),
80 working_directory: None,
81 success_exit_codes: vec![0],
82 disable_logging: false,
83 lossy_output: false,
84 }
85 }
86}
87
88impl CommandRunner {
89 /// Creates a command runner with default settings.
90 ///
91 /// # Returns
92 ///
93 /// A runner with a 10-second timeout, inherited working directory, success
94 /// exit code `0`, and strict UTF-8 output text accessors.
95 #[inline]
96 pub fn new() -> Self {
97 Self::default()
98 }
99
100 /// Sets the command timeout.
101 ///
102 /// # Parameters
103 ///
104 /// * `timeout` - Maximum duration allowed for each command.
105 ///
106 /// # Returns
107 ///
108 /// The updated command runner.
109 #[inline]
110 pub const fn timeout(mut self, timeout: Duration) -> Self {
111 self.timeout = Some(timeout);
112 self
113 }
114
115 /// Disables timeout handling.
116 ///
117 /// # Returns
118 ///
119 /// The updated command runner.
120 #[inline]
121 pub const fn without_timeout(mut self) -> Self {
122 self.timeout = None;
123 self
124 }
125
126 /// Sets the default working directory.
127 ///
128 /// # Parameters
129 ///
130 /// * `working_directory` - Directory used when a command has no
131 /// per-command working directory override.
132 ///
133 /// # Returns
134 ///
135 /// The updated command runner.
136 #[inline]
137 pub fn working_directory<P>(mut self, working_directory: P) -> Self
138 where
139 P: Into<PathBuf>,
140 {
141 self.working_directory = Some(working_directory.into());
142 self
143 }
144
145 /// Sets the only exit code treated as successful.
146 ///
147 /// # Parameters
148 ///
149 /// * `exit_code` - Exit code considered successful.
150 ///
151 /// # Returns
152 ///
153 /// The updated command runner.
154 #[inline]
155 pub fn success_exit_code(mut self, exit_code: i32) -> Self {
156 self.success_exit_codes = vec![exit_code];
157 self
158 }
159
160 /// Sets all exit codes treated as successful.
161 ///
162 /// # Parameters
163 ///
164 /// * `exit_codes` - Exit codes considered successful.
165 ///
166 /// # Returns
167 ///
168 /// The updated command runner.
169 #[inline]
170 pub fn success_exit_codes(mut self, exit_codes: &[i32]) -> Self {
171 self.success_exit_codes = exit_codes.to_vec();
172 self
173 }
174
175 /// Enables or disables command execution logs.
176 ///
177 /// # Parameters
178 ///
179 /// * `disable_logging` - `true` to suppress runner logs.
180 ///
181 /// # Returns
182 ///
183 /// The updated command runner.
184 #[inline]
185 pub const fn disable_logging(mut self, disable_logging: bool) -> Self {
186 self.disable_logging = disable_logging;
187 self
188 }
189
190 /// Configures whether output text accessors use lossy UTF-8 conversion.
191 ///
192 /// # Parameters
193 ///
194 /// * `lossy_output` - `true` to replace invalid UTF-8 bytes with the
195 /// Unicode replacement character when [`CommandOutput::stdout`] or
196 /// [`CommandOutput::stderr`] is called.
197 ///
198 /// # Returns
199 ///
200 /// The updated command runner.
201 #[inline]
202 pub const fn lossy_output(mut self, lossy_output: bool) -> Self {
203 self.lossy_output = lossy_output;
204 self
205 }
206
207 /// Returns the configured timeout.
208 ///
209 /// # Returns
210 ///
211 /// `Some(duration)` when timeout handling is enabled, otherwise `None`.
212 #[inline]
213 pub const fn configured_timeout(&self) -> Option<Duration> {
214 self.timeout
215 }
216
217 /// Returns the default working directory.
218 ///
219 /// # Returns
220 ///
221 /// `Some(path)` when a default working directory is configured, otherwise
222 /// `None` to inherit the current process working directory.
223 #[inline]
224 pub fn configured_working_directory(&self) -> Option<&Path> {
225 self.working_directory.as_deref()
226 }
227
228 /// Returns the configured successful exit codes.
229 ///
230 /// # Returns
231 ///
232 /// Borrowed list of exit codes treated as successful.
233 #[inline]
234 pub fn configured_success_exit_codes(&self) -> &[i32] {
235 &self.success_exit_codes
236 }
237
238 /// Returns whether logging is disabled.
239 ///
240 /// # Returns
241 ///
242 /// `true` when runner logs are disabled.
243 #[inline]
244 pub const fn is_logging_disabled(&self) -> bool {
245 self.disable_logging
246 }
247
248 /// Returns whether output text accessors use lossy UTF-8 conversion.
249 ///
250 /// # Returns
251 ///
252 /// `true` when invalid UTF-8 bytes are replaced before output is returned
253 /// by [`CommandOutput::stdout`] or [`CommandOutput::stderr`].
254 #[inline]
255 pub const fn is_lossy_output_enabled(&self) -> bool {
256 self.lossy_output
257 }
258
259 /// Runs a command and captures stdout and stderr.
260 ///
261 /// This method blocks the caller thread until the child process exits or
262 /// the configured timeout is reached. Captured output is always retained
263 /// as raw bytes. If lossy output mode is enabled, invalid UTF-8 is replaced
264 /// only for [`CommandOutput::stdout`] and [`CommandOutput::stderr`]; byte
265 /// accessors still return the original process output.
266 ///
267 /// # Parameters
268 ///
269 /// * `command` - Structured command to run.
270 ///
271 /// # Returns
272 ///
273 /// Captured output when the process exits with a configured success code.
274 ///
275 /// # Errors
276 ///
277 /// Returns [`CommandError`] if the process cannot be spawned, cannot be
278 /// waited on, times out, cannot be killed after timing out, emits output
279 /// that cannot be read, or exits with a code not configured as successful.
280 pub fn run(&self, command: Command) -> Result<CommandOutput, CommandError> {
281 let command_text = command.display_command();
282 if !self.disable_logging {
283 log::info!("Running command: {command_text}");
284 }
285
286 let mut process_command = ProcessCommand::new(command.program());
287 process_command.args(command.arguments());
288 process_command.stdin(Stdio::null());
289 process_command.stdout(Stdio::piped());
290 process_command.stderr(Stdio::piped());
291
292 if let Some(working_directory) = command
293 .working_directory_override()
294 .or(self.working_directory.as_deref())
295 {
296 process_command.current_dir(working_directory);
297 }
298
299 for (key, value) in command.environment() {
300 process_command.env(key, value);
301 }
302
303 let mut child = match process_command.spawn() {
304 Ok(child) => child,
305 Err(source) => return Err(spawn_failed(&command_text, source)),
306 };
307
308 let stdout = match child.stdout.take() {
309 Some(stdout) => stdout,
310 None => return Err(output_pipe_error(&command_text, OutputStream::Stdout)),
311 };
312 let stderr = match child.stderr.take() {
313 Some(stderr) => stderr,
314 None => return Err(output_pipe_error(&command_text, OutputStream::Stderr)),
315 };
316 let stdout_reader = read_stdout(stdout);
317 let stderr_reader = read_stderr(stderr);
318
319 let start = Instant::now();
320 let exit_status = loop {
321 let maybe_status = match child.try_wait() {
322 Ok(status) => status,
323 Err(source) => return Err(wait_failed(&command_text, source)),
324 };
325 if let Some(status) = maybe_status {
326 break status;
327 }
328 if let Some(timeout) = self.timeout
329 && start.elapsed() >= timeout
330 {
331 if let Err(source) = child.kill() {
332 return Err(kill_failed(command_text, timeout, source));
333 }
334 let exit_status = match child.wait() {
335 Ok(status) => status,
336 Err(source) => return Err(wait_failed(&command_text, source)),
337 };
338 let output = collect_output(
339 &command_text,
340 exit_status.code(),
341 start.elapsed(),
342 self.lossy_output,
343 stdout_reader,
344 stderr_reader,
345 )?;
346 return Err(CommandError::TimedOut {
347 command: command_text,
348 timeout,
349 output: Box::new(output),
350 });
351 }
352 thread::sleep(next_sleep(self.timeout, start.elapsed()));
353 };
354
355 let output = collect_output(
356 &command_text,
357 exit_status.code(),
358 start.elapsed(),
359 self.lossy_output,
360 stdout_reader,
361 stderr_reader,
362 )?;
363
364 if output
365 .exit_code()
366 .is_some_and(|exit_code| self.success_exit_codes.contains(&exit_code))
367 {
368 if !self.disable_logging {
369 log::info!(
370 "Finished command `{}` in {:?}.",
371 command_text,
372 output.elapsed()
373 );
374 }
375 Ok(output)
376 } else {
377 if !self.disable_logging {
378 log::error!(
379 "Command `{}` exited with code {:?}.",
380 command_text,
381 output.exit_code()
382 );
383 }
384 Err(CommandError::UnexpectedExit {
385 command: command_text,
386 exit_code: output.exit_code(),
387 expected: self.success_exit_codes.clone(),
388 output: Box::new(output),
389 })
390 }
391 }
392}
393
394/// Spawns a reader thread for stdout.
395///
396/// # Parameters
397///
398/// * `stdout` - Child process stdout pipe.
399///
400/// # Returns
401///
402/// Join handle resolving to captured stdout bytes.
403fn read_stdout(stdout: ChildStdout) -> thread::JoinHandle<io::Result<Vec<u8>>> {
404 thread::spawn(move || read_all(stdout))
405}
406
407/// Spawns a reader thread for stderr.
408///
409/// # Parameters
410///
411/// * `stderr` - Child process stderr pipe.
412///
413/// # Returns
414///
415/// Join handle resolving to captured stderr bytes.
416fn read_stderr(stderr: ChildStderr) -> thread::JoinHandle<io::Result<Vec<u8>>> {
417 thread::spawn(move || read_all(stderr))
418}
419
420/// Reads all bytes from a child output stream.
421///
422/// # Parameters
423///
424/// * `reader` - Pipe reader to drain.
425///
426/// # Returns
427///
428/// All bytes read from the pipe.
429///
430/// # Errors
431///
432/// Returns the I/O error reported by [`Read::read_to_end`].
433fn read_all<R>(mut reader: R) -> io::Result<Vec<u8>>
434where
435 R: Read,
436{
437 let mut buffer = Vec::new();
438 reader.read_to_end(&mut buffer)?;
439 Ok(buffer)
440}
441
442/// Builds a process spawn failure.
443///
444/// # Parameters
445///
446/// * `command` - Human-readable command text for diagnostics.
447/// * `source` - I/O error reported by process spawning.
448///
449/// # Returns
450///
451/// A command error preserving the command text and source error.
452fn spawn_failed(command: &str, source: io::Error) -> CommandError {
453 CommandError::SpawnFailed {
454 command: command.to_owned(),
455 source,
456 }
457}
458
459/// Builds a process wait failure.
460///
461/// # Parameters
462///
463/// * `command` - Human-readable command text for diagnostics.
464/// * `source` - I/O error reported while waiting for the process.
465///
466/// # Returns
467///
468/// A command error preserving the command text and source error.
469fn wait_failed(command: &str, source: io::Error) -> CommandError {
470 CommandError::WaitFailed {
471 command: command.to_owned(),
472 source,
473 }
474}
475
476/// Builds a timed-out process kill failure.
477///
478/// # Parameters
479///
480/// * `command` - Human-readable command text for diagnostics.
481/// * `timeout` - Timeout that had been exceeded.
482/// * `source` - I/O error reported while killing the process.
483///
484/// # Returns
485///
486/// A command error preserving timeout and kill-failure context.
487fn kill_failed(command: String, timeout: Duration, source: io::Error) -> CommandError {
488 CommandError::KillFailed {
489 command,
490 timeout,
491 source,
492 }
493}
494
495/// Collects reader-thread results into a command output value.
496///
497/// # Parameters
498///
499/// * `command` - Human-readable command text for diagnostics.
500/// * `exit_code` - Process exit code, if available.
501/// * `elapsed` - Observed command duration.
502/// * `lossy_output` - Whether output text accessors should replace invalid
503/// UTF-8 bytes.
504/// * `stdout_reader` - Reader thread for stdout.
505/// * `stderr_reader` - Reader thread for stderr.
506///
507/// # Returns
508///
509/// Command output containing both captured streams.
510///
511/// # Errors
512///
513/// Returns [`CommandError::ReadOutputFailed`] if either stream cannot be read
514/// or if a reader thread panics.
515fn collect_output(
516 command: &str,
517 exit_code: Option<i32>,
518 elapsed: Duration,
519 lossy_output: bool,
520 stdout_reader: thread::JoinHandle<io::Result<Vec<u8>>>,
521 stderr_reader: thread::JoinHandle<io::Result<Vec<u8>>>,
522) -> Result<CommandOutput, CommandError> {
523 let stdout = join_output_reader(command, OutputStream::Stdout, stdout_reader)?;
524 let stderr = join_output_reader(command, OutputStream::Stderr, stderr_reader)?;
525 Ok(CommandOutput::new(
526 exit_code,
527 stdout,
528 stderr,
529 elapsed,
530 lossy_output,
531 ))
532}
533
534/// Joins one output reader and maps failures to command errors.
535///
536/// # Parameters
537///
538/// * `command` - Human-readable command text for diagnostics.
539/// * `stream` - Stream associated with the reader.
540/// * `reader` - Join handle to collect.
541///
542/// # Returns
543///
544/// Captured bytes for the requested stream.
545///
546/// # Errors
547///
548/// Returns [`CommandError::ReadOutputFailed`] when the reader reports I/O
549/// failure or panics.
550fn join_output_reader(
551 command: &str,
552 stream: OutputStream,
553 reader: thread::JoinHandle<io::Result<Vec<u8>>>,
554) -> Result<Vec<u8>, CommandError> {
555 match reader.join() {
556 Ok(Ok(output)) => Ok(output),
557 Ok(Err(source)) => Err(CommandError::ReadOutputFailed {
558 command: command.to_owned(),
559 stream,
560 source,
561 }),
562 Err(_) => Err(CommandError::ReadOutputFailed {
563 command: command.to_owned(),
564 stream,
565 source: io::Error::other("output reader thread panicked"),
566 }),
567 }
568}
569
570/// Builds an internal missing-pipe error.
571///
572/// # Parameters
573///
574/// * `command` - Human-readable command text for diagnostics.
575/// * `stream` - Missing output stream.
576///
577/// # Returns
578///
579/// A command error describing the missing pipe.
580fn output_pipe_error(command: &str, stream: OutputStream) -> CommandError {
581 CommandError::ReadOutputFailed {
582 command: command.to_owned(),
583 stream,
584 source: io::Error::other(format!("{} pipe was not created", stream.as_str())),
585 }
586}
587
588/// Calculates how long to sleep before polling the child again.
589///
590/// # Parameters
591///
592/// * `timeout` - Optional command timeout.
593/// * `elapsed` - Elapsed command duration.
594///
595/// # Returns
596///
597/// A short polling delay that does not intentionally sleep past the timeout.
598fn next_sleep(timeout: Option<Duration>, elapsed: Duration) -> Duration {
599 if let Some(timeout) = timeout
600 && let Some(remaining) = timeout.checked_sub(elapsed)
601 {
602 return remaining.min(WAIT_POLL_INTERVAL);
603 }
604 WAIT_POLL_INTERVAL
605}
606
607/// Coverage-only hooks for exercising defensive process-runner branches.
608#[cfg(coverage)]
609#[doc(hidden)]
610pub mod coverage_support {
611 use std::{
612 io::{
613 self,
614 Read,
615 },
616 panic,
617 thread,
618 time::Duration,
619 };
620
621 use super::{
622 WAIT_POLL_INTERVAL,
623 collect_output,
624 join_output_reader,
625 kill_failed,
626 next_sleep,
627 output_pipe_error,
628 read_all,
629 spawn_failed,
630 wait_failed,
631 };
632 use crate::OutputStream;
633
634 /// Exercises internal error helpers that cannot be reached reliably through
635 /// real OS process execution.
636 ///
637 /// # Returns
638 ///
639 /// Diagnostic strings built from each exercised error path.
640 pub fn exercise_defensive_paths() -> Vec<String> {
641 let mut diagnostics = Vec::new();
642 diagnostics.push(spawn_failed("spawn", io::Error::other("spawn failed")).to_string());
643 diagnostics.push(wait_failed("wait", io::Error::other("wait failed")).to_string());
644 diagnostics.push(
645 kill_failed(
646 "kill".to_owned(),
647 Duration::from_millis(1),
648 io::Error::other("kill failed"),
649 )
650 .to_string(),
651 );
652 diagnostics.push(output_pipe_error("pipe", OutputStream::Stdout).to_string());
653 diagnostics.push(output_pipe_error("pipe", OutputStream::Stderr).to_string());
654
655 let read_error =
656 read_all(FailingReader).expect_err("failing reader should report read error");
657 diagnostics.push(read_error.to_string());
658
659 let failed_stdout = thread::spawn(|| Err(io::Error::other("collect stdout failed")));
660 let empty_stderr = thread::spawn(|| Ok(Vec::new()));
661 diagnostics.push(
662 collect_output(
663 "collect-stdout",
664 Some(0),
665 Duration::ZERO,
666 false,
667 failed_stdout,
668 empty_stderr,
669 )
670 .expect_err("stdout collection error should be mapped")
671 .to_string(),
672 );
673
674 let empty_stdout = thread::spawn(|| Ok(Vec::new()));
675 let failed_stderr = thread::spawn(|| Err(io::Error::other("collect stderr failed")));
676 diagnostics.push(
677 collect_output(
678 "collect-stderr",
679 Some(0),
680 Duration::ZERO,
681 false,
682 empty_stdout,
683 failed_stderr,
684 )
685 .expect_err("stderr collection error should be mapped")
686 .to_string(),
687 );
688
689 let reader_error = thread::spawn(|| Err(io::Error::other("reader failed")));
690 diagnostics.push(
691 join_output_reader("reader", OutputStream::Stdout, reader_error)
692 .expect_err("reader error should be mapped")
693 .to_string(),
694 );
695
696 let previous_hook = panic::take_hook();
697 panic::set_hook(Box::new(|_| {}));
698 let panicked_reader = thread::spawn(|| -> io::Result<Vec<u8>> {
699 panic!("output reader panic");
700 });
701 let panic_error = join_output_reader("panic", OutputStream::Stderr, panicked_reader)
702 .expect_err("reader panic should be mapped")
703 .to_string();
704 panic::set_hook(previous_hook);
705 diagnostics.push(panic_error);
706
707 diagnostics.push(format!("{:?}", next_sleep(None, Duration::ZERO)));
708 diagnostics.push(format!(
709 "{:?}",
710 next_sleep(Some(Duration::from_millis(1)), Duration::from_millis(2)),
711 ));
712 diagnostics.push(format!(
713 "{:?}",
714 next_sleep(Some(Duration::from_secs(1)), Duration::ZERO),
715 ));
716 diagnostics.push(format!("{WAIT_POLL_INTERVAL:?}"));
717 diagnostics
718 }
719
720 /// Reader that always fails when read.
721 struct FailingReader;
722
723 impl Read for FailingReader {
724 /// Reports a synthetic read failure.
725 ///
726 /// # Parameters
727 ///
728 /// * `_buffer` - Destination buffer intentionally left untouched.
729 ///
730 /// # Returns
731 ///
732 /// Always returns an I/O error.
733 fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
734 Err(io::Error::other("read failed"))
735 }
736 }
737}