• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

qubit-ltd / rs-command / 7cafc937-466b-4fdb-b474-5d1318af48ad

03 May 2026 03:48PM UTC coverage: 99.724% (-0.3%) from 100.0%
7cafc937-466b-4fdb-b474-5d1318af48ad

push

circleci

Haixing-Hu
fix: cover defensive command runner paths

3 of 3 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

722 of 724 relevant lines covered (99.72%)

29.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.65
/src/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
 ******************************************************************************/
10
use std::{
11
    path::{
12
        Path,
13
        PathBuf,
14
    },
15
    time::Duration,
16
};
17

18
pub(crate) mod captured_output;
19
pub(crate) mod command_io;
20
pub(crate) mod error_mapping;
21
pub(crate) mod finished_command;
22
pub(crate) mod managed_child_process;
23
pub(crate) mod output_capture_error;
24
pub(crate) mod output_capture_options;
25
pub(crate) mod output_collector;
26
pub(crate) mod output_reader;
27
pub(crate) mod output_tee;
28
pub(crate) mod prepared_command;
29
pub(crate) mod process_launcher;
30
pub(crate) mod process_setup;
31
pub(crate) mod running_command;
32
pub(crate) mod stdin_pipe;
33
pub(crate) mod stdin_writer;
34
pub(crate) mod wait_policy;
35

36
use command_io::CommandIo;
37
use error_mapping::{
38
    output_pipe_error,
39
    spawn_failed,
40
};
41
use finished_command::FinishedCommand;
42
use output_capture_options::OutputCaptureOptions;
43
use output_collector::read_output_stream;
44
use prepared_command::PreparedCommand;
45
use process_launcher::spawn_child;
46
use running_command::RunningCommand;
47
use stdin_pipe::write_stdin_bytes;
48

49
use 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.
61
pub 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)]
72
pub 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

91
impl 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 {
53✔
100
        Self {
53✔
101
            timeout: None,
53✔
102
            working_directory: None,
53✔
103
            success_exit_codes: vec![0],
53✔
104
            disable_logging: false,
53✔
105
            max_stdout_bytes: None,
53✔
106
            max_stderr_bytes: None,
53✔
107
            stdout_file: None,
53✔
108
            stderr_file: None,
53✔
109
        }
53✔
110
    }
53✔
111
}
112

113
impl 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 {
53✔
122
        Self::default()
53✔
123
    }
53✔
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 {
5✔
136
        self.timeout = Some(timeout);
5✔
137
        self
5✔
138
    }
5✔
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 {
1✔
147
        self.timeout = None;
1✔
148
        self
1✔
149
    }
1✔
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
1✔
163
    where
1✔
164
        P: Into<PathBuf>,
1✔
165
    {
166
        self.working_directory = Some(working_directory.into());
1✔
167
        self
1✔
168
    }
1✔
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 {
1✔
181
        self.success_exit_codes = vec![exit_code];
1✔
182
        self
1✔
183
    }
1✔
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 {
1✔
196
        self.success_exit_codes = exit_codes.to_vec();
1✔
197
        self
1✔
198
    }
1✔
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 {
3✔
211
        self.disable_logging = disable_logging;
3✔
212
        self
3✔
213
    }
3✔
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 {
7✔
230
        self.max_stdout_bytes = Some(max_bytes);
7✔
231
        self
7✔
232
    }
7✔
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 {
3✔
249
        self.max_stderr_bytes = Some(max_bytes);
3✔
250
        self
3✔
251
    }
3✔
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 {
1✔
264
        self.max_stdout_bytes = Some(max_bytes);
1✔
265
        self.max_stderr_bytes = Some(max_bytes);
1✔
266
        self
1✔
267
    }
1✔
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
5✔
284
    where
5✔
285
        P: Into<PathBuf>,
5✔
286
    {
287
        self.stdout_file = Some(path.into());
5✔
288
        self
5✔
289
    }
5✔
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
4✔
306
    where
4✔
307
        P: Into<PathBuf>,
4✔
308
    {
309
        self.stderr_file = Some(path.into());
4✔
310
        self
4✔
311
    }
4✔
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> {
1✔
320
        self.timeout
1✔
321
    }
1✔
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> {
1✔
331
        self.working_directory.as_deref()
1✔
332
    }
1✔
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] {
2✔
341
        &self.success_exit_codes
2✔
342
    }
2✔
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 {
2✔
351
        self.disable_logging
2✔
352
    }
2✔
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> {
2✔
361
        self.max_stdout_bytes
2✔
362
    }
2✔
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> {
2✔
371
        self.max_stderr_bytes
2✔
372
    }
2✔
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> {
2✔
381
        self.stdout_file.as_deref()
2✔
382
    }
2✔
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> {
2✔
391
        self.stderr_file.as_deref()
2✔
392
    }
2✔
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> {
48✔
426
        let PreparedCommand {
427
            command_text,
44✔
428
            process_command,
44✔
429
            stdin_bytes,
44✔
430
            stdout_file,
44✔
431
            stderr_file,
44✔
432
            stdout_file_path,
44✔
433
            stderr_file_path,
44✔
434
        } = PreparedCommand::prepare(
48✔
435
            command,
48✔
436
            self.working_directory.as_deref(),
48✔
437
            self.stdout_file.as_deref(),
48✔
438
            self.stderr_file.as_deref(),
48✔
439
        )?;
4✔
440

441
        if !self.disable_logging {
44✔
442
            log::info!("Running command: {command_text}");
42✔
443
        }
2✔
444

445
        let mut child_process = match spawn_child(process_command, self.timeout.is_some()) {
44✔
446
            Ok(child_process) => child_process,
42✔
447
            Err(source) => return Err(spawn_failed(&command_text, source)),
2✔
448
        };
449

450
        let stdin_writer = write_stdin_bytes(&command_text, child_process.as_mut(), stdin_bytes)?;
42✔
451

452
        let stdout = match child_process.stdout().take() {
42✔
453
            Some(stdout) => stdout,
42✔
UNCOV
454
            None => return Err(output_pipe_error(&command_text, OutputStream::Stdout)),
×
455
        };
456
        let stderr = match child_process.stderr().take() {
42✔
457
            Some(stderr) => stderr,
42✔
UNCOV
458
            None => return Err(output_pipe_error(&command_text, OutputStream::Stderr)),
×
459
        };
460
        let stdout_reader = read_output_stream(
42✔
461
            Box::new(stdout),
42✔
462
            OutputCaptureOptions::new(self.max_stdout_bytes, stdout_file, stdout_file_path),
42✔
463
        );
464
        let stderr_reader = read_output_stream(
42✔
465
            Box::new(stderr),
42✔
466
            OutputCaptureOptions::new(self.max_stderr_bytes, stderr_file, stderr_file_path),
42✔
467
        );
468
        let command_io = CommandIo::new(stdout_reader, stderr_reader, stdin_writer);
42✔
469
        let finished = RunningCommand::new(command_text, child_process, command_io)
42✔
470
            .wait_for_completion(self.timeout)?;
42✔
471
        let FinishedCommand {
472
            command_text,
38✔
473
            output,
38✔
474
        } = finished;
38✔
475

476
        if output
38✔
477
            .exit_code()
38✔
478
            .is_some_and(|exit_code| self.success_exit_codes.contains(&exit_code))
38✔
479
        {
480
            if !self.disable_logging {
33✔
481
                log::info!(
32✔
482
                    "Finished command `{}` in {:?}.",
483
                    command_text,
484
                    output.elapsed()
18✔
485
                );
486
            }
1✔
487
            Ok(output)
33✔
488
        } else {
489
            if !self.disable_logging {
5✔
490
                log::error!(
4✔
491
                    "Command `{}` exited with code {:?}.",
492
                    command_text,
493
                    output.exit_code()
2✔
494
                );
495
            }
1✔
496
            Err(CommandError::UnexpectedExit {
5✔
497
                command: command_text,
5✔
498
                exit_code: output.exit_code(),
5✔
499
                expected: self.success_exit_codes.clone(),
5✔
500
                output: Box::new(output),
5✔
501
            })
5✔
502
        }
503
    }
48✔
504
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc