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}