Skip to main content

Crate cmd_proc

Crate cmd_proc 

Source
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

Featurestd / tokio processcmd-proc
Debug loggingNoneAutomatic debug logging of commands before execution
Exit code checkManualAutomatic - non-zero exits return Err
Output captureReturns raw Output structTypestate: .stdout_capture().string(), .stderr_capture().bytes()
Builder patternMutable references (&mut self)Owned builder with method chaining
Stdin dataRequires manual pipe setupSimple .stdin_bytes() method
Env var namesAccepts any AsRef<OsStr>EnvVariableName type with compile-time validation
Error typeSeparate io::Error and ExitStatusUnified CommandError with both
Process spawningDirect .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

FromMethodTo
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

TypeMethodReturn
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(())
}

Structs§

CaptureAll
Builder for capturing both stdout and stderr from a command.
CaptureAllResult
Result from capturing both stdout and stderr.
CaptureSingle
Builder for capturing a single stream from a command.
CaptureSingleResult
Result from capturing a single stream.
Command
CommandError
EnvVariableName
Validated environment variable name.
Stderr
Marker type for stderr stream.
Stdout
Marker type for stdout stream.

Enums§

EnvVariableNameError

Traits§

StreamMarker
Marker trait for stream type parameters.