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    /// Maximum stdout bytes retained in memory.
82    max_stdout_bytes: Option<usize>,
83    /// Maximum stderr bytes retained in memory.
84    max_stderr_bytes: Option<usize>,
85    /// File that receives a streaming copy of stdout.
86    stdout_file: Option<PathBuf>,
87    /// File that receives a streaming copy of stderr.
88    stderr_file: Option<PathBuf>,
89}
90
91impl Default for CommandRunner {
92    /// Creates a command runner with the default exit-code policy.
93    ///
94    /// # Returns
95    ///
96    /// A runner with no timeout, inherited working directory, success exit code
97    /// `0`, unlimited in-memory output capture, and no output tee files.
98    #[inline]
99    fn default() -> Self {
100        Self {
101            timeout: None,
102            working_directory: None,
103            success_exit_codes: vec![0],
104            disable_logging: false,
105            max_stdout_bytes: None,
106            max_stderr_bytes: None,
107            stdout_file: None,
108            stderr_file: None,
109        }
110    }
111}
112
113impl CommandRunner {
114    /// Creates a command runner with default settings.
115    ///
116    /// # Returns
117    ///
118    /// A runner with no timeout, inherited working directory, success exit code
119    /// `0`, unlimited in-memory output capture, and no output tee files.
120    #[inline]
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Sets the command timeout.
126    ///
127    /// # Parameters
128    ///
129    /// * `timeout` - Maximum duration allowed for each command.
130    ///
131    /// # Returns
132    ///
133    /// The updated command runner.
134    #[inline]
135    pub const fn timeout(mut self, timeout: Duration) -> Self {
136        self.timeout = Some(timeout);
137        self
138    }
139
140    /// Disables timeout handling.
141    ///
142    /// # Returns
143    ///
144    /// The updated command runner.
145    #[inline]
146    pub const fn without_timeout(mut self) -> Self {
147        self.timeout = None;
148        self
149    }
150
151    /// Sets the default working directory.
152    ///
153    /// # Parameters
154    ///
155    /// * `working_directory` - Directory used when a command has no
156    ///   per-command working directory override.
157    ///
158    /// # Returns
159    ///
160    /// The updated command runner.
161    #[inline]
162    pub fn working_directory<P>(mut self, working_directory: P) -> Self
163    where
164        P: Into<PathBuf>,
165    {
166        self.working_directory = Some(working_directory.into());
167        self
168    }
169
170    /// Sets the only exit code treated as successful.
171    ///
172    /// # Parameters
173    ///
174    /// * `exit_code` - Exit code considered successful.
175    ///
176    /// # Returns
177    ///
178    /// The updated command runner.
179    #[inline]
180    pub fn success_exit_code(mut self, exit_code: i32) -> Self {
181        self.success_exit_codes = vec![exit_code];
182        self
183    }
184
185    /// Sets all exit codes treated as successful.
186    ///
187    /// # Parameters
188    ///
189    /// * `exit_codes` - Exit codes considered successful.
190    ///
191    /// # Returns
192    ///
193    /// The updated command runner.
194    #[inline]
195    pub fn success_exit_codes(mut self, exit_codes: &[i32]) -> Self {
196        self.success_exit_codes = exit_codes.to_vec();
197        self
198    }
199
200    /// Enables or disables command execution logs.
201    ///
202    /// # Parameters
203    ///
204    /// * `disable_logging` - `true` to suppress runner logs.
205    ///
206    /// # Returns
207    ///
208    /// The updated command runner.
209    #[inline]
210    pub const fn disable_logging(mut self, disable_logging: bool) -> Self {
211        self.disable_logging = disable_logging;
212        self
213    }
214
215    /// Sets the maximum stdout bytes retained in memory.
216    ///
217    /// The reader still drains the complete stdout stream. Bytes beyond this
218    /// limit are not retained in [`CommandOutput`], but they are still written to
219    /// a configured stdout tee file.
220    ///
221    /// # Parameters
222    ///
223    /// * `max_bytes` - Maximum number of stdout bytes to retain.
224    ///
225    /// # Returns
226    ///
227    /// The updated command runner.
228    #[inline]
229    pub const fn max_stdout_bytes(mut self, max_bytes: usize) -> Self {
230        self.max_stdout_bytes = Some(max_bytes);
231        self
232    }
233
234    /// Sets the maximum stderr bytes retained in memory.
235    ///
236    /// The reader still drains the complete stderr stream. Bytes beyond this
237    /// limit are not retained in [`CommandOutput`], but they are still written to
238    /// a configured stderr tee file.
239    ///
240    /// # Parameters
241    ///
242    /// * `max_bytes` - Maximum number of stderr bytes to retain.
243    ///
244    /// # Returns
245    ///
246    /// The updated command runner.
247    #[inline]
248    pub const fn max_stderr_bytes(mut self, max_bytes: usize) -> Self {
249        self.max_stderr_bytes = Some(max_bytes);
250        self
251    }
252
253    /// Sets the same in-memory capture limit for stdout and stderr.
254    ///
255    /// # Parameters
256    ///
257    /// * `max_bytes` - Maximum number of bytes retained for each stream.
258    ///
259    /// # Returns
260    ///
261    /// The updated command runner.
262    #[inline]
263    pub const fn max_output_bytes(mut self, max_bytes: usize) -> Self {
264        self.max_stdout_bytes = Some(max_bytes);
265        self.max_stderr_bytes = Some(max_bytes);
266        self
267    }
268
269    /// Streams stdout to a file while still capturing it in memory.
270    ///
271    /// The file is created or truncated before the command is spawned. Combine
272    /// this with [`Self::max_stdout_bytes`] to avoid unbounded memory use for
273    /// large stdout streams.
274    ///
275    /// # Parameters
276    ///
277    /// * `path` - Destination file path for stdout bytes.
278    ///
279    /// # Returns
280    ///
281    /// The updated command runner.
282    #[inline]
283    pub fn tee_stdout_to_file<P>(mut self, path: P) -> Self
284    where
285        P: Into<PathBuf>,
286    {
287        self.stdout_file = Some(path.into());
288        self
289    }
290
291    /// Streams stderr 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_stderr_bytes`] to avoid unbounded memory use for
295    /// large stderr streams.
296    ///
297    /// # Parameters
298    ///
299    /// * `path` - Destination file path for stderr bytes.
300    ///
301    /// # Returns
302    ///
303    /// The updated command runner.
304    #[inline]
305    pub fn tee_stderr_to_file<P>(mut self, path: P) -> Self
306    where
307        P: Into<PathBuf>,
308    {
309        self.stderr_file = Some(path.into());
310        self
311    }
312
313    /// Returns the configured timeout.
314    ///
315    /// # Returns
316    ///
317    /// `Some(duration)` when timeout handling is enabled, otherwise `None`.
318    #[inline]
319    pub const fn configured_timeout(&self) -> Option<Duration> {
320        self.timeout
321    }
322
323    /// Returns the default working directory.
324    ///
325    /// # Returns
326    ///
327    /// `Some(path)` when a default working directory is configured, otherwise
328    /// `None` to inherit the current process working directory.
329    #[inline]
330    pub fn configured_working_directory(&self) -> Option<&Path> {
331        self.working_directory.as_deref()
332    }
333
334    /// Returns the configured successful exit codes.
335    ///
336    /// # Returns
337    ///
338    /// Borrowed list of exit codes treated as successful.
339    #[inline]
340    pub fn configured_success_exit_codes(&self) -> &[i32] {
341        &self.success_exit_codes
342    }
343
344    /// Returns whether logging is disabled.
345    ///
346    /// # Returns
347    ///
348    /// `true` when runner logs are disabled.
349    #[inline]
350    pub const fn is_logging_disabled(&self) -> bool {
351        self.disable_logging
352    }
353
354    /// Returns the configured stdout capture limit.
355    ///
356    /// # Returns
357    ///
358    /// `Some(max_bytes)` when stdout capture is limited, otherwise `None`.
359    #[inline]
360    pub const fn configured_max_stdout_bytes(&self) -> Option<usize> {
361        self.max_stdout_bytes
362    }
363
364    /// Returns the configured stderr capture limit.
365    ///
366    /// # Returns
367    ///
368    /// `Some(max_bytes)` when stderr capture is limited, otherwise `None`.
369    #[inline]
370    pub const fn configured_max_stderr_bytes(&self) -> Option<usize> {
371        self.max_stderr_bytes
372    }
373
374    /// Returns the stdout tee file path.
375    ///
376    /// # Returns
377    ///
378    /// `Some(path)` when stdout is streamed to a file, otherwise `None`.
379    #[inline]
380    pub fn configured_stdout_file(&self) -> Option<&Path> {
381        self.stdout_file.as_deref()
382    }
383
384    /// Returns the stderr tee file path.
385    ///
386    /// # Returns
387    ///
388    /// `Some(path)` when stderr is streamed to a file, otherwise `None`.
389    #[inline]
390    pub fn configured_stderr_file(&self) -> Option<&Path> {
391        self.stderr_file.as_deref()
392    }
393
394    /// Runs a command and captures stdout and stderr.
395    ///
396    /// This method blocks the caller thread until the child process exits or
397    /// the configured timeout is reached. When a timeout is configured, Unix
398    /// children run as leaders of new process groups and Windows children run
399    /// in Job Objects. This lets timeout killing target the process tree
400    /// instead of only the direct child process. Without a configured timeout,
401    /// commands use the platform's normal process-spawning behavior.
402    ///
403    /// Captured output is retained as raw bytes up to the configured per-stream
404    /// limits. Reader threads still drain complete streams so the child is not
405    /// blocked on full pipes. Use [`CommandOutput::stdout_text`] and
406    /// [`CommandOutput::stderr_text`] for strict UTF-8 text, or
407    /// [`CommandOutput::stdout_lossy_text`] and
408    /// [`CommandOutput::stderr_lossy_text`] when invalid UTF-8 should be
409    /// replaced.
410    ///
411    /// # Parameters
412    ///
413    /// * `command` - Structured command to run.
414    ///
415    /// # Returns
416    ///
417    /// Captured output when the process exits with a configured success code.
418    ///
419    /// # Errors
420    ///
421    /// Returns [`CommandError`] if the process cannot be spawned, cannot be
422    /// waited on, times out, cannot be killed after timing out, emits output
423    /// that cannot be read or written to a tee file, cannot receive configured
424    /// stdin, or exits with a code not configured as successful.
425    pub fn run(&self, command: Command) -> Result<CommandOutput, CommandError> {
426        let PreparedCommand {
427            command_text,
428            process_command,
429            stdin_bytes,
430            stdout_file,
431            stderr_file,
432            stdout_file_path,
433            stderr_file_path,
434        } = PreparedCommand::prepare(
435            command,
436            self.working_directory.as_deref(),
437            self.stdout_file.as_deref(),
438            self.stderr_file.as_deref(),
439        )?;
440
441        if !self.disable_logging {
442            log::info!("Running command: {command_text}");
443        }
444
445        let mut child_process = match spawn_child(process_command, self.timeout.is_some()) {
446            Ok(child_process) => child_process,
447            Err(source) => return Err(spawn_failed(&command_text, source)),
448        };
449
450        let stdin_writer = write_stdin_bytes(&command_text, child_process.as_mut(), stdin_bytes)?;
451
452        let stdout = match child_process.stdout().take() {
453            Some(stdout) => stdout,
454            None => return Err(output_pipe_error(&command_text, OutputStream::Stdout)),
455        };
456        let stderr = match child_process.stderr().take() {
457            Some(stderr) => stderr,
458            None => return Err(output_pipe_error(&command_text, OutputStream::Stderr)),
459        };
460        let stdout_reader = read_output_stream(
461            Box::new(stdout),
462            OutputCaptureOptions::new(self.max_stdout_bytes, stdout_file, stdout_file_path),
463        );
464        let stderr_reader = read_output_stream(
465            Box::new(stderr),
466            OutputCaptureOptions::new(self.max_stderr_bytes, stderr_file, stderr_file_path),
467        );
468        let command_io = CommandIo::new(stdout_reader, stderr_reader, stdin_writer);
469        let finished = RunningCommand::new(command_text, child_process, command_io)
470            .wait_for_completion(self.timeout)?;
471        let FinishedCommand {
472            command_text,
473            output,
474        } = finished;
475
476        if output
477            .exit_code()
478            .is_some_and(|exit_code| self.success_exit_codes.contains(&exit_code))
479        {
480            if !self.disable_logging {
481                log::info!(
482                    "Finished command `{}` in {:?}.",
483                    command_text,
484                    output.elapsed()
485                );
486            }
487            Ok(output)
488        } else {
489            if !self.disable_logging {
490                log::error!(
491                    "Command `{}` exited with code {:?}.",
492                    command_text,
493                    output.exit_code()
494                );
495            }
496            Err(CommandError::UnexpectedExit {
497                command: command_text,
498                exit_code: output.exit_code(),
499                expected: self.success_exit_codes.clone(),
500                output: Box::new(output),
501            })
502        }
503    }
504}