I once wrote a deployment script in Bash that grew to 800 lines with nested conditionals, string interpolation bugs, and error handling that amounted to “hope for the best.” Rewrote it in Rust using std::process::Command — same functionality, but with actual error handling and type safety. The number of failed deployments dropped to zero.
Command — The Builder
std::process::Command is a builder for spawning child processes. You construct the command, configure it, then either run it to completion or spawn it and interact with its I/O.
use std::process::Command;
fn main() {
// Simple command — run and wait
let output = Command::new("echo")
.arg("Hello from Rust!")
.output()
.expect("Failed to run echo");
println!("Status: {}", output.status);
println!("Stdout: {}", String::from_utf8_lossy(&output.stdout));
println!("Stderr: {}", String::from_utf8_lossy(&output.stderr));
}
Three ways to execute:
output()— runs to completion, captures stdout/stderr into memorystatus()— runs to completion, inherits stdout/stderr (prints to terminal), returns just the exit codespawn()— starts the process and returns aChildhandle immediately, lets you interact with stdin/stdout/stderr
use std::process::Command;
fn main() {
// status() — output goes directly to terminal
let status = Command::new("ls")
.arg("-la")
.arg("/tmp")
.status()
.expect("Failed to run ls");
println!("\nExit code: {}", status);
println!("Success: {}", status.success());
// output() — capture everything
let output = Command::new("uname")
.arg("-a")
.output()
.expect("Failed to run uname");
if output.status.success() {
let system_info = String::from_utf8_lossy(&output.stdout);
println!("System: {}", system_info.trim());
} else {
let err = String::from_utf8_lossy(&output.stderr);
eprintln!("Error: {err}");
}
}
Arguments and Environment
use std::process::Command;
use std::collections::HashMap;
fn main() {
// Multiple arguments — each one separate (NOT shell-style)
let output = Command::new("git")
.args(["log", "--oneline", "-5"])
.output()
.expect("git failed");
println!("Recent commits:\n{}", String::from_utf8_lossy(&output.stdout));
// Set environment variables
let output = Command::new("env")
.env("MY_VAR", "hello")
.env("MY_OTHER_VAR", "world")
.output()
.expect("env failed");
// Clear all environment and set only what you specify
let output = Command::new("env")
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", "/tmp")
.output()
.expect("env failed");
println!("Minimal env:\n{}", String::from_utf8_lossy(&output.stdout));
// Set working directory
let output = Command::new("pwd")
.current_dir("/tmp")
.output()
.expect("pwd failed");
println!("Working dir: {}", String::from_utf8_lossy(&output.stdout).trim());
}
Important: Command does not invoke a shell. Arguments are passed directly to the executable. This means no shell expansion, no globbing, no pipes, no redirects. That’s a security feature — it prevents shell injection attacks.
use std::process::Command;
fn main() {
// This does NOT work — the * won't be expanded
let output = Command::new("ls")
.arg("/tmp/*.txt")
.output()
.expect("ls failed");
// If you actually need shell features, invoke the shell explicitly
// (but be careful with untrusted input!)
let output = Command::new("sh")
.args(["-c", "ls /tmp/*.txt 2>/dev/null | head -5"])
.output()
.expect("shell failed");
println!("{}", String::from_utf8_lossy(&output.stdout));
}
Spawning and Streaming I/O
spawn() gives you a running child process with handles to its stdin, stdout, and stderr:
use std::io::{self, BufRead, BufReader, Write};
use std::process::{Command, Stdio};
fn main() -> io::Result<()> {
// Spawn a process and write to its stdin
let mut child = Command::new("sort")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
// Write data to stdin
if let Some(ref mut stdin) = child.stdin {
writeln!(stdin, "banana")?;
writeln!(stdin, "apple")?;
writeln!(stdin, "cherry")?;
writeln!(stdin, "date")?;
}
// Drop stdin to signal EOF — the child needs this to finish
drop(child.stdin.take());
// Read sorted output
let output = child.wait_with_output()?;
println!("Sorted:\n{}", String::from_utf8_lossy(&output.stdout));
Ok(())
}
That drop(child.stdin.take()) is crucial. Many programs read until EOF, and EOF on a pipe only happens when all write handles are closed. If you forget this, your program deadlocks — the child waits for more input, and you wait for the child to finish.
Streaming Output Line by Line
use std::io::{self, BufRead, BufReader};
use std::process::{Command, Stdio};
fn main() -> io::Result<()> {
// Long-running command — read output as it arrives
let mut child = Command::new("ping")
.args(["-c", "4", "127.0.0.1"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Stream stdout line by line
let stdout = child.stdout.take().expect("Failed to capture stdout");
let reader = BufReader::new(stdout);
for line in reader.lines() {
let line = line?;
println!("[PING] {line}");
}
let status = child.wait()?;
println!("\nPing finished with: {status}");
Ok(())
}
Pipes Between Processes
You can connect one process’s stdout to another’s stdin — the equivalent of shell pipes:
use std::io;
use std::process::{Command, Stdio};
fn main() -> io::Result<()> {
// Equivalent to: echo "one\ntwo\nthree\nfour" | sort | uniq
let echo = Command::new("echo")
.arg("one\ntwo\nthree\nfour\ntwo\none")
.stdout(Stdio::piped())
.spawn()?;
let sort = Command::new("sort")
.stdin(echo.stdout.unwrap())
.stdout(Stdio::piped())
.spawn()?;
let uniq = Command::new("uniq")
.stdin(sort.stdout.unwrap())
.output()?;
println!("Result:\n{}", String::from_utf8_lossy(&uniq.stdout));
Ok(())
}
Each process here is a real OS process running concurrently. The data flows through OS pipes between them, just like in a shell. Rust just gives you explicit handles instead of the | syntax.
Error Handling
Command execution can fail in two different ways:
- The command couldn’t start — wrong path, missing executable, permission denied. This is
Errfromspawn()/output()/status(). - The command started but exited with an error — the program ran but returned a non-zero exit code. This is
Okwith a non-success status.
use std::io;
use std::process::Command;
fn run_command(program: &str, args: &[&str]) -> io::Result<String> {
let output = Command::new(program)
.args(args)
.output()?; // Handles case 1: couldn't start
if !output.status.success() {
// Case 2: started but failed
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::new(
io::ErrorKind::Other,
format!("{program} failed ({}): {stderr}", output.status),
));
}
String::from_utf8(output.stdout)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn main() {
// Success case
match run_command("echo", &["hello"]) {
Ok(output) => println!("Output: {}", output.trim()),
Err(e) => eprintln!("Error: {e}"),
}
// Command not found
match run_command("nonexistent_command_xyz", &[]) {
Ok(output) => println!("Output: {output}"),
Err(e) => eprintln!("Error: {e}"),
}
// Command exists but fails
match run_command("ls", &["/nonexistent/path/xyz"]) {
Ok(output) => println!("Output: {output}"),
Err(e) => eprintln!("Error: {e}"),
}
}
Process Exit
Your own program can control its exit:
use std::process;
fn validate_config(path: &str) -> Result<String, String> {
// Simulate validation
if path.ends_with(".toml") {
Ok("Config valid".to_string())
} else {
Err(format!("Expected .toml file, got: {path}"))
}
}
fn main() {
let config_path = "config.json"; // Wrong extension for demo
match validate_config(config_path) {
Ok(msg) => println!("{msg}"),
Err(e) => {
eprintln!("Fatal: {e}");
process::exit(1);
}
}
// process::exit() does NOT run destructors.
// If you need destructors to run, return from main() instead:
// fn main() -> Result<(), Box<dyn std::error::Error>> { ... }
// Get your own process ID
println!("My PID: {}", process::id());
}
Prefer returning from main() over calling process::exit(). Exit codes work the same way, but returning lets destructors run (closing files, flushing buffers, cleaning up temp files).
Killing Child Processes
use std::io;
use std::process::{Command, Stdio};
use std::time::Duration;
use std::thread;
fn main() -> io::Result<()> {
let mut child = Command::new("sleep")
.arg("30")
.stdout(Stdio::null())
.spawn()?;
println!("Started sleep process (PID: {})", child.id());
// Give it a moment, then kill it
thread::sleep(Duration::from_secs(1));
child.kill()?;
println!("Killed the process");
// Must still wait() to reap the zombie
let status = child.wait()?;
println!("Exit status: {status}");
Ok(())
}
Always call wait() or wait_with_output() after kill(). On Unix, a killed process becomes a zombie until its parent reads its exit status. Forgetting to wait() leaks zombie processes.
Practical Example: Build Script Runner
use std::io::{self, Write};
use std::process::{Command, Stdio};
use std::time::Instant;
struct Step {
name: &'static str,
program: &'static str,
args: Vec<&'static str>,
}
fn run_steps(steps: &[Step]) -> io::Result<()> {
let total_start = Instant::now();
for (i, step) in steps.iter().enumerate() {
print!("[{}/{}] {} ... ", i + 1, steps.len(), step.name);
io::stdout().flush()?;
let start = Instant::now();
let output = Command::new(step.program)
.args(&step.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
let elapsed = start.elapsed();
if output.status.success() {
println!("OK ({:.1}s)", elapsed.as_secs_f64());
} else {
println!("FAILED ({:.1}s)", elapsed.as_secs_f64());
eprintln!("\nSTDERR:\n{}", String::from_utf8_lossy(&output.stderr));
eprintln!("STDOUT:\n{}", String::from_utf8_lossy(&output.stdout));
return Err(io::Error::new(
io::ErrorKind::Other,
format!("Step '{}' failed with {}", step.name, output.status),
));
}
}
println!(
"\nAll {} steps passed in {:.1}s",
steps.len(),
total_start.elapsed().as_secs_f64()
);
Ok(())
}
fn main() -> io::Result<()> {
let steps = vec![
Step { name: "Check formatting", program: "cargo", args: vec!["fmt", "--check"] },
Step { name: "Lint", program: "cargo", args: vec!["clippy", "--", "-D", "warnings"] },
Step { name: "Test", program: "cargo", args: vec!["test"] },
Step { name: "Build release", program: "cargo", args: vec!["build", "--release"] },
];
run_steps(&steps)?;
Ok(())
}
This is a pattern I come back to again and again. Step-by-step execution with timing, captured output on failure, and early termination on error. The type system guarantees you handle both “command not found” and “command failed” — try getting that guarantee from a shell script.
std::process is one of those modules that bridges Rust’s safe world with the messy reality of OS processes. The API is small but complete, and understanding it means you can replace fragile shell scripts with robust, testable Rust programs.