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}