Skip to main content

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}