Expand description
§cmd-proc - Process Command Builder
A wrapper around tokio::process::Command providing debug logging, stronger input types, and a fluent builder API with automatic exit code checking.
Status: Pre-1.0 - exists to serve mbj/mrs monorepo, expect breaking changes without notice.
§Why cmd-proc?
std::process::Command (and its async wrapper tokio::process::Command) is powerful but requires verbose boilerplate for common patterns:
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// std/tokio process - capturing stdout as string
let output = std::process::Command::new("echo")
.arg("hello")
.output()?;
if !output.status.success() {
return Err("non-zero exit".into());
}
let stdout = String::from_utf8(output.stdout)?;
assert_eq!(stdout, "hello\n");
// cmd-proc - same operation
let stdout = cmd_proc::Command::new("echo")
.argument("hello")
.stdout_capture()
.string().await?;
assert_eq!(stdout, "hello\n");
Ok(())
}§Key differences from std / tokio process
| Feature | std / tokio process | cmd-proc |
|---|---|---|
| Debug logging | None | Automatic debug logging of commands before execution |
| Exit code check | Manual | Automatic - non-zero exits return Err |
| Output capture | Returns raw Output struct | Typestate: .stdout_capture().string(), .stderr_capture().bytes() |
| Builder pattern | Mutable references (&mut self) | Owned builder with method chaining |
| Stdin data | Requires manual pipe setup | Simple .stdin_bytes() method |
| Env var names | Accepts any AsRef<OsStr> | EnvVariableName type with compile-time validation |
| Error type | Separate io::Error and ExitStatus | Unified CommandError with both |
| Process spawning | Direct .spawn() on Command | .build() for native tokio::process::Command access |
§Design philosophy
- Debug logging: Every command execution is logged via the
logcrate at debug level, making it easy to trace what commands are being run. - Stronger input types:
EnvVariableNameprevents invalid environment variable names (empty or containing=) at compile time rather than runtime. - Typestate capture pattern: Stream capture methods transition between builder types (
Command->CaptureSingle<S>->CaptureAll), enforcing valid API usage at compile time. You can’t call.bytes()on a double-capture builder, and you can’t accidentally forget which stream you’re capturing. - Default exit code checking: Capture methods treat non-zero exit as an error by default, preventing accidentally ignored failures. Use
.accept_nonzero_exit()to opt out when needed. - Fluent API: Chain configuration methods naturally without
&mut selfgymnastics.
§Usage
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use cmd_proc::Command;
// Capture stdout as string
let output = Command::new("echo")
.argument("hello")
.stdout_capture()
.string().await?;
assert_eq!(output, "hello\n");
// Capture stderr as bytes
let errors = Command::new("sh")
.arguments(["-c", "echo err >&2"])
.stderr_capture()
.bytes().await?;
assert_eq!(errors, b"err\n");
// Run without capturing (just check success)
Command::new("true")
.status().await?;
// Capture both stdout and stderr
let result = Command::new("sh")
.arguments(["-c", "echo out; echo err >&2; exit 1"])
.stdout_capture()
.stderr_capture()
.accept_nonzero_exit()
.run().await?;
assert_eq!(result.stdout, b"out\n");
assert_eq!(result.stderr, b"err\n");
// Pass stdin data
let output = Command::new("cat")
.stdin_bytes(b"hello world")
.stdout_capture()
.string().await?;
assert_eq!(output, "hello world");
// Redirect streams to /dev/null
Command::new("sh")
.arguments(["-c", "echo noise >&2"])
.stderr_null()
.status().await?;
Ok(())
}§Stream Capture Typestate
Stream configuration uses a typestate pattern with three builder types. Each method consumes self and returns the appropriate type, enforcing valid transitions at compile time.
§State transition matrix
| From | Method | To |
|---|---|---|
Command | .stdout_capture() | CaptureSingle<Stdout> |
Command | .stderr_capture() | CaptureSingle<Stderr> |
Command | .stdout_null() | Command |
Command | .stderr_null() | Command |
Command | .stdout_inherit() | Command |
Command | .stderr_inherit() | Command |
CaptureSingle<Stdout> | .stderr_capture() | CaptureAll |
CaptureSingle<Stdout> | .stderr_null() | CaptureSingle<Stdout> |
CaptureSingle<Stdout> | .stderr_inherit() | CaptureSingle<Stdout> |
CaptureSingle<Stdout> | .stdout_null() | Command |
CaptureSingle<Stdout> | .stdout_inherit() | Command |
CaptureSingle<Stderr> | .stdout_capture() | CaptureAll |
CaptureSingle<Stderr> | .stdout_null() | CaptureSingle<Stderr> |
CaptureSingle<Stderr> | .stdout_inherit() | CaptureSingle<Stderr> |
CaptureSingle<Stderr> | .stderr_null() | Command |
CaptureSingle<Stderr> | .stderr_inherit() | Command |
CaptureAll | .stdout_null() | CaptureSingle<Stderr> |
CaptureAll | .stdout_inherit() | CaptureSingle<Stderr> |
CaptureAll | .stderr_null() | CaptureSingle<Stdout> |
CaptureAll | .stderr_inherit() | CaptureSingle<Stdout> |
§Terminal methods
| Type | Method | Return |
|---|---|---|
Command | .status() | Result<(), CommandError> |
Command | .build() | tokio::process::Command |
CaptureSingle<S> | .run() | Result<CaptureSingleResult, CommandError> |
CaptureSingle<S> | .bytes() | Result<Vec<u8>, CommandError> |
CaptureSingle<S> | .string() | Result<String, CommandError> |
CaptureAll | .run() | Result<CaptureAllResult, CommandError> |
§Result types
CaptureSingleResult:bytes: Vec<u8>,status: ExitStatusCaptureAllResult:stdout: Vec<u8>,stderr: Vec<u8>,status: ExitStatus
All builders support .accept_nonzero_exit() to allow non-zero exit codes without error.
§Arguments vs Options
In CLI terminology:
- Argument: positional value (e.g.,
git clone <url>) - Option: named parameter with value (e.g.,
--message "text",-o file)
cmd-proc provides methods for both:
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use cmd_proc::Command;
// Arguments (positional)
let output = Command::new("echo")
.argument("one") // single argument
.arguments(["two", "three"]) // multiple arguments
.optional_argument(Some("four")) // argument only if Some
.optional_argument(None::<&str>) // skipped when None
.stdout_capture()
.string().await?;
assert_eq!(output, "one two three four\n");
// Options (name + value pairs)
let output = Command::new("echo")
.option("-n", "hello") // required option
.optional_option("--unused", None::<&str>) // skipped when None
.stdout_capture()
.string().await?;
assert_eq!(output, "hello");
// optional_option simplifies the common flag + value pattern:
let maybe_author: Option<&str> = Some("Alice");
let output = Command::new("echo")
.argument("-n")
.optional_option("--author", maybe_author)
.stdout_capture()
.string().await?;
assert_eq!(output, "--author Alice");
Ok(())
}§Native Process Access
For interactive processes or other use cases beyond capture, use .build() to get
the underlying tokio::process::Command:
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use cmd_proc::Command;
use tokio::io::AsyncBufReadExt;
let mut child = Command::new("echo")
.argument("hello")
.build()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;
// Read a line from stdout
let mut line = String::new();
tokio::io::BufReader::new(child.stdout.as_mut().unwrap())
.read_line(&mut line)
.await?;
assert_eq!(line, "hello\n");
// Close stdin to signal shutdown
drop(child.stdin.take());
// Wait for exit
child.wait().await?;
Ok(())
}§Environment Variables
Environment variable names are validated via EnvVariableName:
- Cannot be empty - an empty name is meaningless
- Cannot contain
=- the OS uses=as separator between name and value; a name containing=corrupts the environment block
std::process::Command::env() silently accepts invalid names, causing mysterious runtime failures. EnvVariableName catches errors at compile time (when from_static_or_panic is used in a const context) or parse time (FromStr).
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use cmd_proc::{Command, EnvVariableName};
// Compile-time validated (panics at compile time if invalid)
const MY_VAR: EnvVariableName = EnvVariableName::from_static_or_panic("MY_VAR");
let output = Command::new("sh")
.arguments(["-c", "echo $MY_VAR"])
.env(&MY_VAR, "hello")
.stdout_capture()
.string().await?;
assert_eq!(output, "hello\n");
// Set multiple variables from an iterator
let vars = [
(EnvVariableName::from_static_or_panic("FOO"), "1"),
(EnvVariableName::from_static_or_panic("BAR"), "2"),
];
Command::new("sh")
.arguments(["-c", "true"])
.envs(vars)
.status().await?;
// Remove a variable from the child environment
const PATH: EnvVariableName = EnvVariableName::from_static_or_panic("PATH");
Command::new("sh")
.arguments(["-c", "true"])
.env_remove(&PATH)
.status().await?;
Ok(())
}§Error Handling
CommandError has two mutually exclusive failure modes: an IO error
(command not found, permission denied) or a non-zero exit status — never both.
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use cmd_proc::Command;
// IO error — command not found
let error = Command::new("./nonexistent").status().await.unwrap_err();
assert!(error.io_error.is_some());
assert!(error.exit_status.is_none());
// Non-zero exit status
let error = Command::new("false").status().await.unwrap_err();
assert!(error.io_error.is_none());
assert!(error.exit_status.is_some());
Ok(())
}Structs§
- Capture
All - Builder for capturing both stdout and stderr from a command.
- Capture
AllResult - Result from capturing both stdout and stderr.
- Capture
Single - Builder for capturing a single stream from a command.
- Capture
Single Result - Result from capturing a single stream.
- Command
- Command
Error - EnvVariable
Name - Validated environment variable name.
- Stderr
- Marker type for stderr stream.
- Stdout
- Marker type for stdout stream.
Enums§
Traits§
- Stream
Marker - Marker trait for stream type parameters.