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}