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