Skip to main content

qubit_command/
command_runner.rs

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