cmd-proc 0.4.0

Process command builder with capture support
Documentation

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 log crate at debug level, making it easy to trace what commands are being run.
  • Stronger input types: EnvVariableName prevents 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 self gymnastics.

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: ExitStatus
  • CaptureAllResult: 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(())
}