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