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    path::{
11        Path,
12        PathBuf,
13    },
14    time::Duration,
15};
16
17pub(crate) mod captured_output;
18pub(crate) mod command_io;
19pub(crate) mod error_mapping;
20pub(crate) mod finished_command;
21pub(crate) mod managed_child_process;
22pub(crate) mod output_capture_error;
23pub(crate) mod output_capture_options;
24pub(crate) mod output_collector;
25pub(crate) mod output_reader;
26pub(crate) mod output_tee;
27pub(crate) mod prepared_command;
28pub(crate) mod process_launcher;
29pub(crate) mod process_setup;
30pub(crate) mod running_command;
31pub(crate) mod stdin_pipe;
32pub(crate) mod stdin_writer;
33pub(crate) mod wait_policy;
34
35use command_io::CommandIo;
36use error_mapping::{
37    output_pipe_error,
38    spawn_failed,
39};
40use finished_command::FinishedCommand;
41use output_capture_options::OutputCaptureOptions;
42use output_collector::read_output_stream;
43use prepared_command::PreparedCommand;
44use process_launcher::spawn_child;
45use running_command::RunningCommand;
46use stdin_pipe::write_stdin_bytes;
47
48use crate::{
49    Command,
50    CommandError,
51    CommandOutput,
52    OutputStream,
53};
54
55/// Predefined ten-second timeout value.
56///
57/// `CommandRunner::new` does not apply this timeout automatically. Use this
58/// constant with [`CommandRunner::timeout`] when callers want a short, explicit
59/// command limit.
60pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
61
62/// Runs external commands and captures their output.
63///
64/// `CommandRunner` runs one [`Command`] synchronously on the caller thread and
65/// returns captured process output. The runner always preserves raw output
66/// bytes. Its lossy-output option controls whether [`CommandOutput::stdout`]
67/// and [`CommandOutput::stderr`] reject invalid UTF-8 or return replacement
68/// characters.
69///
70/// # Author
71///
72/// Haixing Hu
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CommandRunner {
75    /// Maximum duration allowed for each command.
76    timeout: Option<Duration>,
77    /// Default working directory used when a command does not override it.
78    working_directory: Option<PathBuf>,
79    /// Exit codes treated as successful.
80    success_exit_codes: Vec<i32>,
81    /// Whether command execution logs are disabled.
82    disable_logging: bool,
83    /// Whether captured text accessors should replace invalid UTF-8 bytes.
84    lossy_output: bool,
85    /// Maximum stdout bytes retained in memory.
86    max_stdout_bytes: Option<usize>,
87    /// Maximum stderr bytes retained in memory.
88    max_stderr_bytes: Option<usize>,
89    /// File that receives a streaming copy of stdout.
90    stdout_file: Option<PathBuf>,
91    /// File that receives a streaming copy of stderr.
92    stderr_file: Option<PathBuf>,
93}
94
95impl Default for CommandRunner {
96    /// Creates a command runner with the default exit-code policy.
97    ///
98    /// # Returns
99    ///
100    /// A runner with no timeout, inherited working directory, success exit code
101    /// `0`, strict UTF-8 output text accessors, unlimited in-memory output
102    /// capture, and no output tee files.
103    #[inline]
104    fn default() -> Self {
105        Self {
106            timeout: None,
107            working_directory: None,
108            success_exit_codes: vec![0],
109            disable_logging: false,
110            lossy_output: false,
111            max_stdout_bytes: None,
112            max_stderr_bytes: None,
113            stdout_file: None,
114            stderr_file: None,
115        }
116    }
117}
118
119impl CommandRunner {
120    /// Creates a command runner with default settings.
121    ///
122    /// # Returns
123    ///
124    /// A runner with no timeout, inherited working directory, success exit code
125    /// `0`, strict UTF-8 output text accessors, unlimited in-memory output
126    /// capture, and no output tee files.
127    #[inline]
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Sets the command timeout.
133    ///
134    /// # Parameters
135    ///
136    /// * `timeout` - Maximum duration allowed for each command.
137    ///
138    /// # Returns
139    ///
140    /// The updated command runner.
141    #[inline]
142    pub const fn timeout(mut self, timeout: Duration) -> Self {
143        self.timeout = Some(timeout);
144        self
145    }
146
147    /// Disables timeout handling.
148    ///
149    /// # Returns
150    ///
151    /// The updated command runner.
152    #[inline]
153    pub const fn without_timeout(mut self) -> Self {
154        self.timeout = None;
155        self
156    }
157
158    /// Sets the default working directory.
159    ///
160    /// # Parameters
161    ///
162    /// * `working_directory` - Directory used when a command has no
163    ///   per-command working directory override.
164    ///
165    /// # Returns
166    ///
167    /// The updated command runner.
168    #[inline]
169    pub fn working_directory<P>(mut self, working_directory: P) -> Self
170    where
171        P: Into<PathBuf>,
172    {
173        self.working_directory = Some(working_directory.into());
174        self
175    }
176
177    /// Sets the only exit code treated as successful.
178    ///
179    /// # Parameters
180    ///
181    /// * `exit_code` - Exit code considered successful.
182    ///
183    /// # Returns
184    ///
185    /// The updated command runner.
186    #[inline]
187    pub fn success_exit_code(mut self, exit_code: i32) -> Self {
188        self.success_exit_codes = vec![exit_code];
189        self
190    }
191
192    /// Sets all exit codes treated as successful.
193    ///
194    /// # Parameters
195    ///
196    /// * `exit_codes` - Exit codes considered successful.
197    ///
198    /// # Returns
199    ///
200    /// The updated command runner.
201    #[inline]
202    pub fn success_exit_codes(mut self, exit_codes: &[i32]) -> Self {
203        self.success_exit_codes = exit_codes.to_vec();
204        self
205    }
206
207    /// Enables or disables command execution logs.
208    ///
209    /// # Parameters
210    ///
211    /// * `disable_logging` - `true` to suppress runner logs.
212    ///
213    /// # Returns
214    ///
215    /// The updated command runner.
216    #[inline]
217    pub const fn disable_logging(mut self, disable_logging: bool) -> Self {
218        self.disable_logging = disable_logging;
219        self
220    }
221
222    /// Configures whether output text accessors use lossy UTF-8 conversion.
223    ///
224    /// # Parameters
225    ///
226    /// * `lossy_output` - `true` to replace invalid UTF-8 bytes with the
227    ///   Unicode replacement character when [`CommandOutput::stdout`] or
228    ///   [`CommandOutput::stderr`] is called.
229    ///
230    /// # Returns
231    ///
232    /// The updated command runner.
233    #[inline]
234    pub const fn lossy_output(mut self, lossy_output: bool) -> Self {
235        self.lossy_output = lossy_output;
236        self
237    }
238
239    /// Sets the maximum stdout bytes retained in memory.
240    ///
241    /// The reader still drains the complete stdout stream. Bytes beyond this
242    /// limit are not retained in [`CommandOutput`], but they are still written to
243    /// a configured stdout tee file.
244    ///
245    /// # Parameters
246    ///
247    /// * `max_bytes` - Maximum number of stdout bytes to retain.
248    ///
249    /// # Returns
250    ///
251    /// The updated command runner.
252    #[inline]
253    pub const fn max_stdout_bytes(mut self, max_bytes: usize) -> Self {
254        self.max_stdout_bytes = Some(max_bytes);
255        self
256    }
257
258    /// Sets the maximum stderr bytes retained in memory.
259    ///
260    /// The reader still drains the complete stderr stream. Bytes beyond this
261    /// limit are not retained in [`CommandOutput`], but they are still written to
262    /// a configured stderr tee file.
263    ///
264    /// # Parameters
265    ///
266    /// * `max_bytes` - Maximum number of stderr bytes to retain.
267    ///
268    /// # Returns
269    ///
270    /// The updated command runner.
271    #[inline]
272    pub const fn max_stderr_bytes(mut self, max_bytes: usize) -> Self {
273        self.max_stderr_bytes = Some(max_bytes);
274        self
275    }
276
277    /// Sets the same in-memory capture limit for stdout and stderr.
278    ///
279    /// # Parameters
280    ///
281    /// * `max_bytes` - Maximum number of bytes retained for each stream.
282    ///
283    /// # Returns
284    ///
285    /// The updated command runner.
286    #[inline]
287    pub const fn max_output_bytes(mut self, max_bytes: usize) -> Self {
288        self.max_stdout_bytes = Some(max_bytes);
289        self.max_stderr_bytes = Some(max_bytes);
290        self
291    }
292
293    /// Streams stdout to a file while still capturing it in memory.
294    ///
295    /// The file is created or truncated before the command is spawned. Combine
296    /// this with [`Self::max_stdout_bytes`] to avoid unbounded memory use for
297    /// large stdout streams.
298    ///
299    /// # Parameters
300    ///
301    /// * `path` - Destination file path for stdout bytes.
302    ///
303    /// # Returns
304    ///
305    /// The updated command runner.
306    #[inline]
307    pub fn tee_stdout_to_file<P>(mut self, path: P) -> Self
308    where
309        P: Into<PathBuf>,
310    {
311        self.stdout_file = Some(path.into());
312        self
313    }
314
315    /// Streams stderr to a file while still capturing it in memory.
316    ///
317    /// The file is created or truncated before the command is spawned. Combine
318    /// this with [`Self::max_stderr_bytes`] to avoid unbounded memory use for
319    /// large stderr streams.
320    ///
321    /// # Parameters
322    ///
323    /// * `path` - Destination file path for stderr bytes.
324    ///
325    /// # Returns
326    ///
327    /// The updated command runner.
328    #[inline]
329    pub fn tee_stderr_to_file<P>(mut self, path: P) -> Self
330    where
331        P: Into<PathBuf>,
332    {
333        self.stderr_file = Some(path.into());
334        self
335    }
336
337    /// Returns the configured timeout.
338    ///
339    /// # Returns
340    ///
341    /// `Some(duration)` when timeout handling is enabled, otherwise `None`.
342    #[inline]
343    pub const fn configured_timeout(&self) -> Option<Duration> {
344        self.timeout
345    }
346
347    /// Returns the default working directory.
348    ///
349    /// # Returns
350    ///
351    /// `Some(path)` when a default working directory is configured, otherwise
352    /// `None` to inherit the current process working directory.
353    #[inline]
354    pub fn configured_working_directory(&self) -> Option<&Path> {
355        self.working_directory.as_deref()
356    }
357
358    /// Returns the configured successful exit codes.
359    ///
360    /// # Returns
361    ///
362    /// Borrowed list of exit codes treated as successful.
363    #[inline]
364    pub fn configured_success_exit_codes(&self) -> &[i32] {
365        &self.success_exit_codes
366    }
367
368    /// Returns whether logging is disabled.
369    ///
370    /// # Returns
371    ///
372    /// `true` when runner logs are disabled.
373    #[inline]
374    pub const fn is_logging_disabled(&self) -> bool {
375        self.disable_logging
376    }
377
378    /// Returns whether output text accessors use lossy UTF-8 conversion.
379    ///
380    /// # Returns
381    ///
382    /// `true` when invalid UTF-8 bytes are replaced before output is returned
383    /// by [`CommandOutput::stdout`] or [`CommandOutput::stderr`].
384    #[inline]
385    pub const fn is_lossy_output_enabled(&self) -> bool {
386        self.lossy_output
387    }
388
389    /// Returns the configured stdout capture limit.
390    ///
391    /// # Returns
392    ///
393    /// `Some(max_bytes)` when stdout capture is limited, otherwise `None`.
394    #[inline]
395    pub const fn configured_max_stdout_bytes(&self) -> Option<usize> {
396        self.max_stdout_bytes
397    }
398
399    /// Returns the configured stderr capture limit.
400    ///
401    /// # Returns
402    ///
403    /// `Some(max_bytes)` when stderr capture is limited, otherwise `None`.
404    #[inline]
405    pub const fn configured_max_stderr_bytes(&self) -> Option<usize> {
406        self.max_stderr_bytes
407    }
408
409    /// Returns the stdout tee file path.
410    ///
411    /// # Returns
412    ///
413    /// `Some(path)` when stdout is streamed to a file, otherwise `None`.
414    #[inline]
415    pub fn configured_stdout_file(&self) -> Option<&Path> {
416        self.stdout_file.as_deref()
417    }
418
419    /// Returns the stderr tee file path.
420    ///
421    /// # Returns
422    ///
423    /// `Some(path)` when stderr is streamed to a file, otherwise `None`.
424    #[inline]
425    pub fn configured_stderr_file(&self) -> Option<&Path> {
426        self.stderr_file.as_deref()
427    }
428
429    /// Runs a command and captures stdout and stderr.
430    ///
431    /// This method blocks the caller thread until the child process exits or
432    /// the configured timeout is reached. When a timeout is configured, Unix
433    /// children run as leaders of new process groups and Windows children run
434    /// in Job Objects. This lets timeout killing target the process tree
435    /// instead of only the direct child process. Without a configured timeout,
436    /// commands use the platform's normal process-spawning behavior.
437    ///
438    /// Captured output is retained as raw bytes up to the configured per-stream
439    /// limits. Reader threads still drain complete streams so the child is not
440    /// blocked on full pipes. If lossy output mode is enabled, invalid UTF-8 is
441    /// replaced only for [`CommandOutput::stdout`] and
442    /// [`CommandOutput::stderr`]; byte accessors still return the retained raw
443    /// process output.
444    ///
445    /// # Parameters
446    ///
447    /// * `command` - Structured command to run.
448    ///
449    /// # Returns
450    ///
451    /// Captured output when the process exits with a configured success code.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`CommandError`] if the process cannot be spawned, cannot be
456    /// waited on, times out, cannot be killed after timing out, emits output
457    /// that cannot be read or written to a tee file, cannot receive configured
458    /// stdin, or exits with a code not configured as successful.
459    pub fn run(&self, command: Command) -> Result<CommandOutput, CommandError> {
460        let PreparedCommand {
461            command_text,
462            process_command,
463            stdin_bytes,
464            stdout_file,
465            stderr_file,
466            stdout_file_path,
467            stderr_file_path,
468        } = PreparedCommand::prepare(
469            command,
470            self.working_directory.as_deref(),
471            self.stdout_file.as_deref(),
472            self.stderr_file.as_deref(),
473        )?;
474
475        if !self.disable_logging {
476            log::info!("Running command: {command_text}");
477        }
478
479        let mut child_process = match spawn_child(process_command, self.timeout.is_some()) {
480            Ok(child_process) => child_process,
481            Err(source) => return Err(spawn_failed(&command_text, source)),
482        };
483
484        let stdin_writer = write_stdin_bytes(&command_text, child_process.as_mut(), stdin_bytes)?;
485
486        let stdout = match child_process.stdout().take() {
487            Some(stdout) => stdout,
488            None => return Err(output_pipe_error(&command_text, OutputStream::Stdout)),
489        };
490        let stderr = match child_process.stderr().take() {
491            Some(stderr) => stderr,
492            None => return Err(output_pipe_error(&command_text, OutputStream::Stderr)),
493        };
494        let stdout_reader = read_output_stream(
495            Box::new(stdout),
496            OutputCaptureOptions::new(self.max_stdout_bytes, stdout_file, stdout_file_path),
497        );
498        let stderr_reader = read_output_stream(
499            Box::new(stderr),
500            OutputCaptureOptions::new(self.max_stderr_bytes, stderr_file, stderr_file_path),
501        );
502        let command_io = CommandIo::new(stdout_reader, stderr_reader, stdin_writer);
503        let finished =
504            RunningCommand::new(command_text, child_process, command_io, self.lossy_output)
505                .wait_for_completion(self.timeout)?;
506        let FinishedCommand {
507            command_text,
508            output,
509        } = finished;
510
511        if output
512            .exit_code()
513            .is_some_and(|exit_code| self.success_exit_codes.contains(&exit_code))
514        {
515            if !self.disable_logging {
516                log::info!(
517                    "Finished command `{}` in {:?}.",
518                    command_text,
519                    output.elapsed()
520                );
521            }
522            Ok(output)
523        } else {
524            if !self.disable_logging {
525                log::error!(
526                    "Command `{}` exited with code {:?}.",
527                    command_text,
528                    output.exit_code()
529                );
530            }
531            Err(CommandError::UnexpectedExit {
532                command: command_text,
533                exit_code: output.exit_code(),
534                expected: self.success_exit_codes.clone(),
535                output: Box::new(output),
536            })
537        }
538    }
539}