Command-Line Programs in Rust
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A Rust command-line program is a native executable that starts in milliseconds, has no runtime to install and behaves
the same on Linux, macOS and Windows, provided its few platform-specific edges (signals, colour, configuration
directories) are handled deliberately. The standard library gives you the raw material: the arguments, the
environment, the three standard streams, Command and ExitCode. That material is documented on
I/O, Files, Processes & Environment, including
a standard-library-only version of the shelf tool, and this page does not repeat it. What this page adds is the
layer of well-known crates that real tools are built from: an argument parser that generates help and validation, an
error-reporting strategy that produces useful messages and stable exit codes, signal handling, terminal-aware colour,
and the per-OS directories for configuration and data.
The running example is the Bookshelf command-line tool shelf (subcommands list, add, loan, return and
import). The page was written against clap 4.6.7, thiserror 2.0.21, anyhow 1.0.104, color-eyre 0.6.5,
human-panic 2.0.8, ctrlc 3.5.2, signal-hook 0.4.5, anstream 1.0.0, owo-colors 4.4.0,
directories 6.0.0 and cross 0.2.5, as listed on crates.io and docs.rs on 2026-10-25, with the 2024 edition of
Rust. Neither cargo nor rustc is installed on the machine used to write this page, so the Rust examples were not
compiled or run; every API name and attribute in them was checked against the crates' documentation instead. Compile
each snippet before relying on it, and prefer cargo add over copying version numbers from here. Terminals, escape
sequences and pseudo-terminals are explained on Terminals, TTYs &
Pseudo-Terminals and Escape Sequences & Terminal
Capabilities; the POSIX process and signal model on POSIX
Processes, Signals & I/O; the Win32 side on Windows Console &
Processes. The design rules behind the choices below (stdout for data, stderr for diagnostics, exit codes, NO_COLOR)
are on Streams & Exit Codes and
TTY, Colour & Interactivity; a first look at clap is on
CLI Frameworks in Go & Rust.
The building blocks
Every concern of a command-line program has a standard-library answer and a crate that most projects use instead of rolling their own.
| Concern | Standard library | Usual crate | Why use the crate |
|---|---|---|---|
Arguments |
|
|
Help, |
Application errors |
|
|
Context messages on every layer and a printable cause chain |
Library or domain errors |
hand-written |
|
|
Developer-facing reports |
the default panic hook |
|
Coloured error reports with spans and backtraces |
User-facing panics |
the default panic hook |
|
A short message and a report file instead of a raw backtrace |
Ctrl+C and termination |
none |
|
Safe handlers on every OS ( |
Terminal detection |
|
none needed |
Stable since Rust 1.70; use it before the tool prints colour, progress bars or prompts |
Colour |
none |
|
Escape codes that are stripped when the output is not a terminal |
Configuration directories |
|
|
XDG on Linux, |
Add them with cargo add, which records current versions:
cargo add clap --features derive,env
cargo add anyhow thiserror human-panic ctrlc directories anstream owo-colors
Source: clap on docs.rs (the derive and env feature flags are documented in the
derive reference)
Structuring the project
A binary crate with a thin main.rs, a cli.rs that only describes the command line, and a run function that does
the work keeps parsing, behaviour and process concerns separate, and makes the behaviour testable without spawning
the executable.
shelf/
Cargo.toml
src/
main.rs process-level concerns: panic hook, parsing, signals, exit code
cli.rs the clap types, and nothing else
error.rs the domain error enum
run.rs one function per subcommand, taking plain data
The rest of this page builds that layout one file at a time.
clap derive in depth
clap’s derive API turns a struct into a parser. Parser is derived on the top-level struct, Subcommand on an enum of
commands, Args on a reusable set of arguments and ValueEnum on an enum whose variants are the allowed values. The
type of each field decides how it is parsed: bool is a flag, Option<T> an optional value, T a required one,
Vec<T> a repeatable one, and Option<Vec<T>> a repeatable optional one.
The top-level parser
// src/cli.rs
use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
/// Manage the Bookshelf catalogue and its loans.
#[derive(Parser, Debug)]
#[command(name = "shelf", version, about, long_about = None, propagate_version = true)]
pub struct Cli {
/// Directory that holds catalog.json.
#[arg(long, env = "SHELF_DATA_DIR", value_name = "DIR", global = true)]
pub data_dir: Option<PathBuf>,
/// How to print results.
#[arg(long, value_enum, default_value_t = Format::Table, global = true)]
pub format: Format,
/// When to use colour.
#[arg(long, value_enum, default_value_t = ColorWhen::Auto, global = true)]
pub color: ColorWhen,
/// Print more diagnostics on stderr (-v, -vv).
#[arg(short, long, action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Format {
/// Aligned columns for people.
Table,
/// One JSON document for programs.
Json,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum ColorWhen {
Auto,
Always,
Never,
}
Source: clap: Derive Reference
What each attribute does:
-
versionwith no value reads the crate version fromCargo.toml;aboutwith no value reads the crate description. Withoutlong_about = None, a doc comment that contains a blank line becomes a separate long help text shown only for--help.propagate_versionmakes every subcommand answer--versionas well. -
global = truemakes an argument valid before or after any subcommand, soshelf list --format jsonandshelf --format json listboth work. -
The
envattribute, given the nameSHELF_DATA_DIR, makes that environment variable a fallback for the option (theenvfeature must be enabled). Anenvattribute without a value derives the variable name from the field in upper case. The documented precedence is the command line, then the environment, then the default value. -
ArgAction::Countturns repeated short flags into a counter, so-vvgivesverbose == 2. Aboolfield getsSetTrueautomatically. -
The doc comment of each field becomes its
--helptext; the doc comment of aValueEnumvariant becomes the help text of that possible value. -
value_enumis required on a field whose type derivesValueEnum.default_value_tneeds the type to round-trip through its textual form, which aValueEnumdoes through the variant names (table,json,auto, …; clap converts variant names to kebab case unless a#[value(rename_all = ...)]attribute says otherwise).
A PathBuf field is parsed from the raw OsString, so a path that is not valid Unicode (possible on Linux and
macOS, and with unpaired surrogates on Windows) still reaches the program unchanged. A String field rejects such a
value with a clap error.
Value parsers and validation
By default clap picks a parser from the field type with value_parser!. Override it with value_parser = ... when a
value needs checking at the command line rather than deep in the program. There are three forms: a built-in such as a
numeric range, a function from &str to Result<T, E>, and a type that implements FromStr (picked automatically).
// src/cli.rs (continued)
/// Accepts "978-0-441-01359-3" or "9780441013593" and returns the 13 digits.
fn parse_isbn(s: &str) -> Result<String, String> {
let digits: String = s.chars().filter(|c| *c != '-').collect();
if digits.len() == 13 && digits.chars().all(|c| c.is_ascii_digit()) {
Ok(digits)
} else {
Err(format!("{s:?} is not an ISBN-13"))
}
}
#[derive(Args, Debug)]
pub struct AddArgs {
/// Title of the book.
pub title: String,
/// ISBN-13, with or without hyphens.
#[arg(long, value_parser = parse_isbn)]
pub isbn: String,
/// Author; repeat the option for several authors.
#[arg(long = "author", required = true)]
pub authors: Vec<String>,
/// Year of first publication.
#[arg(long, value_parser = clap::value_parser!(u16).range(1..=2100))]
pub year: u16,
}
Source: clap: Derive Reference (the value_parser and action
attributes) and clap: Derive Tutorial (validation)
Three details are easy to miss. A Vec<T> field is optional unless required = true is added, because its inferred
requirement is "zero or more occurrences". A value parser can transform as well as validate, as parse_isbn does by
returning the normalised digits. And when the parser’s error type is a String, that string is the text clap prints
after invalid value, so write it as a complete, specific sentence fragment.
Argument groups and relations
An Args struct creates an implicit ArgGroup. For a struct the group is multiple = true by default, so a
rule such as "exactly one of these" needs required = true, multiple = false:
// src/cli.rs (continued)
#[derive(Args, Debug)]
#[group(required = true, multiple = false)]
pub struct MemberRef {
/// Member number.
#[arg(long)]
pub member_id: Option<u32>,
/// Member e-mail address.
#[arg(long)]
pub member_email: Option<String>,
}
#[derive(Args, Debug)]
pub struct LoanArgs {
/// ISBN-13 of the book to lend.
#[arg(value_parser = parse_isbn)]
pub isbn: String,
#[command(flatten)]
pub member: MemberRef,
/// Loan length in days.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=60), default_value_t = 14)]
pub days: u32,
}
Source: clap: Derive Reference (ArgGroup attributes) and clap: Derive Tutorial (argument relations)
shelf loan 9780441013593 now fails with a missing-argument error that names both options, and passing both
--member-id and --member-email fails with an argument-conflict error. For relations between two individual
arguments the same attribute syntax forwards to the builder methods, for example #[arg(requires = "...", conflicts_with = "...")]; any Arg, Command or ArgGroup method can be used as an attribute (the derive reference
calls these "raw attributes").
Subcommand enums
// src/cli.rs (continued)
#[derive(Args, Debug)]
pub struct ListArgs {
/// Show only books that are not on loan.
#[arg(long)]
pub available: bool,
/// Maximum number of rows.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=500), default_value_t = 50)]
pub limit: u32,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// List the books in the catalogue.
List(ListArgs),
/// Add a book to the catalogue.
Add(AddArgs),
/// Lend a book to a member.
Loan(LoanArgs),
/// Take a lent book back.
Return {
/// ISBN-13 of the book.
#[arg(value_parser = parse_isbn)]
isbn: String,
},
/// Import books from CSV files.
Import {
/// One or more CSV files.
#[arg(required = true)]
files: Vec<PathBuf>,
},
}
Source: clap: Derive Reference (Subcommand types)
A tuple variant that wraps an Args struct keeps each subcommand’s options in a type of its own, which is easier to
pass to a function than a struct-style variant. A required Command field (not wrapped in Option) makes clap show the
help text when the user types a bare shelf; an Option<Command> makes the subcommand optional, which is the right
shape for a tool that does something useful without one. A variant named Return becomes the subcommand return, and
the kebab-case conversion turns ListAvailable into list-available.
Parse errors, exit codes and testing
Cli::parse() prints the error or the help text and ends the process. According to the clap documentation, a parse
error is printed to stderr and exits with status 2, while --help and --version print to stdout and exit with 0.
That matches the convention of using 2 for usage errors. When the program needs control, for example to log first or to
replace the message, use try_parse, which returns a clap::error::Error:
use clap::Parser;
use shelf::cli::Cli;
fn parse_or_exit() -> Cli {
match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
// `exit_code()` is 0 for --help and --version and 2 for real errors;
// `exit()` also chooses stdout or stderr correctly.
err.exit()
}
}
}
Source: clap::error::Error
Because the command line is a type, it can be tested without a process. debug_assert catches mistakes in the
definition itself (duplicate short options, a group that refers to a missing argument) in a single test, and
try_parse_from exercises real invocations:
#[cfg(test)]
mod tests {
use clap::error::ErrorKind;
use clap::{CommandFactory, Parser};
use crate::cli::Cli;
#[test]
fn definition_is_consistent() {
Cli::command().debug_assert();
}
#[test]
fn loan_needs_exactly_one_member() {
let none = Cli::try_parse_from(["shelf", "loan", "9780441013593"]).unwrap_err();
assert_eq!(none.kind(), ErrorKind::MissingRequiredArgument);
let both = Cli::try_parse_from([
"shelf", "loan", "9780441013593", "--member-id", "7", "--member-email", "ana@example.org",
])
.unwrap_err();
assert_eq!(both.kind(), ErrorKind::ArgumentConflict);
}
}
Source: clap: Derive Tutorial (testing)
Shell completions and man pages are generated from the same definitions by the companion crates clap_complete and
clap_mangen; both are introduced on
CLI Frameworks in Go & Rust and
Documenting CLIs. Installing those completions into each shell is the
subject of Shells, Completions & Shell Integration.
Error reporting
A command-line program has two audiences for an error: the person who typed the command and the developer who must fix the bug. They need different output, and the crates below split along that line.
| Crate | Use it for | Shape |
|---|---|---|
|
The error type of a module that callers may match on |
|
|
The application layer: |
|
|
Tools whose users are developers |
A fork of |
|
Tools whose users are not developers |
|
The usual split is thiserror in the part of the program that has meaningful failure modes and anyhow on top of it.
The domain errors of shelf decide the exit code, so they are an enum:
// src/error.rs
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Debug, thiserror::Error)]
pub enum ShelfError {
#[error("no book with ISBN {0}")]
NotFound(String),
#[error("book {0} is already on loan")]
AlreadyLoaned(String),
#[error("cannot read {}", path.display())]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl ShelfError {
/// Distinct, documented exit codes let scripts react without parsing messages.
pub fn exit_code(&self) -> ExitCode {
match self {
ShelfError::NotFound(_) => ExitCode::from(3),
ShelfError::AlreadyLoaned(_) => ExitCode::from(4),
ShelfError::Read { .. } => ExitCode::from(74), // EX_IOERR in sysexits.h
}
}
}
Source: thiserror
anyhow adds context as the error travels up, so the final message reads like a sentence from the outside in:
// src/run.rs (excerpt)
use anyhow::{Context, Result};
use crate::error::ShelfError;
pub fn import_file(path: &std::path::Path) -> Result<usize> {
let text = std::fs::read_to_string(path).map_err(|source| ShelfError::Read {
path: path.to_path_buf(),
source,
})?;
let books = parse_csv(&text).with_context(|| format!("invalid catalogue file {}", path.display()))?;
Ok(books.len())
}
Source: anyhow
The display of an anyhow::Error depends on the format specifier: {} prints only the outermost message, {:#}
prints the whole chain on one line separated by colons, and {:?} prints the chain as a multi-line "Caused by" list
(with a backtrace when RUST_BACKTRACE is set). Returning anyhow::Result<()> from main prints the {:?} form after
Error: and exits with status 1, which is convenient but gives the program no say over the exit code. A tool with
documented exit codes handles the final Err itself:
// src/main.rs
mod cli;
mod error;
mod run;
use std::process::ExitCode;
use clap::Parser;
use crate::cli::Cli;
use crate::error::ShelfError;
fn main() -> ExitCode {
// In release builds this replaces the default panic message; set RUST_BACKTRACE=1
// (or run a debug build) to get the raw output again.
human_panic::setup_panic!();
let cli = Cli::parse();
match run::dispatch(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => report(&err),
}
}
fn report(err: &anyhow::Error) -> ExitCode {
if is_broken_pipe(err) {
return ExitCode::from(141); // what a shell reports for a process killed by SIGPIPE (128 + 13)
}
eprintln!("shelf: {err:#}");
match err.downcast_ref::<ShelfError>() {
Some(domain) => domain.exit_code(),
None => ExitCode::FAILURE,
}
}
fn is_broken_pipe(err: &anyhow::Error) -> bool {
err.chain()
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
.any(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
}
Source: std::process::ExitCode, anyhow and human-panic
human_panic::setup_panic!() leaves the raw panic output available in a debug build or when RUST_BACKTRACE=1 is
set, according to its documentation; in a release build a panic prints a short message and writes a TOML report file to a temporary directory, and the
message says that nothing is sent anywhere automatically. For a tool aimed at developers, color-eyre fills the same
slot the other way round: call color_eyre::install()? first in main, return color_eyre::Result<()>, and add
context with the WrapErr trait. The two are not meant to be combined; pick one hook.
A panic ends the main thread with exit status 101 on every platform, which is a different number from any exit code chosen above; reserve panics for bugs, not for bad input.
Writing output: streams, terminals, pipes and colour
Detect the terminal
std::io::IsTerminal is implemented for the standard streams and for files, and replaces the older isatty-style
crates:
use std::io::{self, IsTerminal};
fn stdout_is_tty() -> bool {
io::stdout().is_terminal()
}
Source: std::io::IsTerminal
Check each stream you use separately. shelf list | less has a terminal on stderr but not on stdout, and a program that
prints a progress bar to stderr only needs to check stderr.
Handle a closed pipe
The Rust runtime sets the Unix SIGPIPE signal to be ignored before main runs. Writing to a pipe whose reader has gone
away (shelf list | head -n 3) therefore returns an ErrorKind::BrokenPipe error instead of killing the process
silently the way a C program is killed. The println! macro treats any write error as a bug and panics, which for a
CLI produces a panic message and exit status 101 for a perfectly normal pipeline. The remedy is to write with writeln!
to a handle whose result you propagate, as the examples below do, and let report above treat BrokenPipe as a quiet
exit. Windows has no SIGPIPE, but a write to a closed pipe also returns an error, which std typically reports as the same kind. The
POSIX side is on POSIX Processes, Signals & I/O.
Colour that disappears when it should
Escape sequences written to a file or to a pipe are noise. anstream solves that at the stream: an
anstream::AutoStream accepts ANSI escape codes and adapts them to what the destination supports, stripping them when it
is not a terminal and honouring NO_COLOR and CLICOLOR_FORCE. Its documentation names anstyle, owo-colors and
color-print as styling crates to pair it with. owo-colors provides the styling methods:
// src/run.rs (excerpt)
use std::io::{self, BufWriter, Write};
use anstream::AutoStream;
use owo_colors::OwoColorize;
use crate::cli::{ColorWhen, Format};
pub struct Book {
pub isbn: String,
pub title: String,
pub year: u16,
pub on_loan: bool,
}
pub fn print_books(books: &[Book], format: Format, color: ColorWhen) -> io::Result<()> {
let raw = io::stdout();
let stream = match color {
ColorWhen::Auto => AutoStream::auto(raw),
ColorWhen::Always => AutoStream::always(raw),
ColorWhen::Never => AutoStream::never(raw),
};
let mut out = BufWriter::new(stream);
match format {
Format::Table => {
writeln!(out, "{:<14} {:<30} {:>4} {}", "ISBN".bold(), "TITLE".bold(), "YEAR".bold(), "STATUS".bold())?;
for b in books {
let status = if b.on_loan { "on loan".yellow().to_string() } else { "available".green().to_string() };
writeln!(out, "{:<14} {:<30} {:>4} {}", b.isbn, b.title, b.year, status)?;
}
}
Format::Json => {
// Data for programs: no colour, one document, stdout only.
write!(out, "[")?;
for (i, b) in books.iter().enumerate() {
if i > 0 { write!(out, ",")?; }
write!(out, "{{\"isbn\":\"{}\",\"title\":{:?},\"year\":{},\"onLoan\":{}}}", b.isbn, b.title, b.year, b.on_loan)?;
}
writeln!(out, "]")?;
}
}
out.flush()
}
Source: anstream and owo-colors
Two cautions. The hand-written JSON above is only to keep the example free of further dependencies: a real tool serialises
with serde_json (see Serialization with serde), because
the Rust Debug formatting of a string is not guaranteed to be valid JSON for every character. And padding with
{:<30} counts characters, not terminal columns, so wide characters misalign tables; width calculation is a topic of
Escape Sequences & Terminal Capabilities.
owo-colors can also decide on its own: with its supports-colors feature,
text.if_supports_color(Stream::Stdout, |t| t.red()) applies the style only when the stream supports colour. The
documentation says that check reads NO_COLOR and FORCE_COLOR, looks at whether the stream is a terminal and at
whether the program runs in CI. Use one mechanism per program (either anstream or if_supports_color) so the
--color option has a single place to take effect.
Signals and graceful shutdown
A command-line program that creates temporary files, holds a lock, or writes a file in several steps should clean up when the user presses Ctrl+C or when a service manager sends a termination request. The rule from POSIX Processes, Signals & I/O applies in Rust too: do almost nothing in the handler itself, set a flag, and let the normal control flow notice it.
ctrlc |
signal-hook |
|
|---|---|---|
Platforms |
Unix and Windows |
Unix; the documentation describes Windows support as limited (only |
What it handles |
Ctrl+C: |
Any Unix signal that may be handled, through flags, pipes or an iterator |
How the handler runs |
In one dedicated thread that the crate starts; there can be only one handler |
Several independent registrations for one signal; ready-made patterns in the |
Source: ctrlc and signal-hook
For a tool that only needs a clean exit on Ctrl+C, ctrlc is the shorter route. The program below imports many files, and
a first Ctrl+C asks it to finish the current file and stop; a second one ends it at once with status 130 (128 + SIGINT,
the shell convention):
// src/main.rs (excerpt)
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
pub fn install_cancellation() -> Result<Arc<AtomicBool>> {
let cancelled = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&cancelled);
ctrlc::set_handler(move || {
if flag.swap(true, Ordering::SeqCst) {
std::process::exit(130); // second request: stop waiting
}
})
.context("cannot install the Ctrl+C handler")?;
Ok(cancelled)
}
// src/run.rs (excerpt)
pub fn import_all(files: &[std::path::PathBuf], cancelled: &AtomicBool) -> Result<usize> {
let mut total = 0;
for path in files {
if cancelled.load(Ordering::SeqCst) {
eprintln!("shelf: interrupted after {total} books");
return Ok(total);
}
total += import_file(path)?;
}
Ok(total)
}
Source: ctrlc (set_handler, the termination feature)
The handler of ctrlc runs on an ordinary thread, so unlike a C signal handler it may allocate and lock. That is a
convenience of the crate, not of the operating system: the underlying mechanism is still a signal, and anything the
handler does can run at an arbitrary moment relative to the main thread. Keeping it to an atomic flag is still the
robust choice.
For the full Unix signal set, signal-hook offers the same two-step pattern in one function. This Unix-only variant
turns the first SIGINT, SIGTERM or SIGHUP into a shutdown request and makes a second one terminate the process:
#[cfg(unix)]
pub fn install_unix_signals() -> std::io::Result<std::sync::Arc<std::sync::atomic::AtomicBool>> {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM};
use signal_hook::flag;
let requested = Arc::new(AtomicBool::new(false));
for sig in [SIGINT, SIGTERM, SIGHUP] {
// Order matters: the conditional shutdown must be registered first. While the flag is
// false it does nothing; the second signal finds the flag set and terminates with 130.
flag::register_conditional_shutdown(sig, 130, Arc::clone(&requested))?;
flag::register(sig, Arc::clone(&requested))?;
}
Ok(requested)
}
Programs built on an async runtime use the runtime’s signal support instead, which is covered on Async Runtimes & Tokio. Neither crate’s documentation claims to handle the Windows console close, logoff and shutdown events (the ones a daemon or installer cares about); those arrive through the console control handler described on Windows Console & Processes.
Configuration and data directories
Where a tool stores its configuration, cache and data is an operating-system convention, and the directories crate
implements the three: the XDG Base Directory specification on Linux, the Standard Directories on macOS and Known
Folders on Windows.
// src/run.rs (excerpt)
use std::path::PathBuf;
use directories::ProjectDirs;
pub fn data_dir(from_option_or_env: Option<PathBuf>) -> Option<PathBuf> {
// The explicit option or SHELF_DATA_DIR (resolved by clap) wins over the platform default.
from_option_or_env.or_else(|| {
ProjectDirs::from("org", "Bookshelf", "shelf").map(|dirs| dirs.data_dir().to_path_buf())
})
}
Source: directories::ProjectDirs
ProjectDirs::from takes a reverse-domain qualifier, an organisation and an application name and returns None when
the system has no valid home directory. The crate documents these results for config_dir() with the arguments
("com", "Foo Corp", "Bar App"):
| OS | config_dir() |
|---|---|
Linux |
|
macOS |
|
Windows |
|
The crate has the same family of functions for the cache (Library/Caches on macOS, AppData\Local on Windows) and
for data. Choose the three names once and never change them, because they decide where existing users' files live. The
precedence rules for combining a configuration file, the environment and options are on
Configuration & Auth; the full table of OS directory differences is
on Cross-Platform Portability.
Windows, macOS and Linux differences
| Topic | Linux | macOS | Windows |
|---|---|---|---|
Arguments |
|
Same as Linux |
|
Ctrl+C |
|
Same as Linux |
A console control event; |
Termination request |
|
Same as Linux |
No |
Closed pipe |
|
Same as Linux |
Error return, mapped to |
Colour |
ANSI sequences on terminals |
ANSI sequences on terminals |
ANSI sequences in modern terminals; |
Per-user directories |
XDG ( |
|
|
Executable name |
|
|
|
Source: directories, ctrlc and signal-hook
Spawning other programs from a Rust tool has one Windows trap that is a security issue. Batch files are interpreted by
cmd.exe, whose quoting rules differ from every other program’s, and in 2024 the Rust project published CVE-2024-24576
for command injection through arguments passed to a batch file with std::process::Command. The fix, in Rust 1.77.2,
makes Command return an InvalidInput error when an argument cannot be escaped safely for cmd.exe. Keep the toolchain
current, never build the argument string for a .bat or .cmd yourself, and treat user-supplied arguments for such a
program as untrusted input. See the Rust security announcement
for the details, and
I/O, Files, Processes & Environment for
Command itself.
Cross-compiling and shipping
rustup target add installs the standard library of another target, but producing a working executable also needs a
linker and, for some targets, system libraries for that platform. Three tools cover the common cases:
| Tool | What it does | Needs |
|---|---|---|
|
Builds for any target whose linker you provide; the simplest route when host and target share an OS family, or for |
rustup and the target’s linker |
|
Runs |
Docker or Podman; its README documents Docker 20.10+ or Podman 3.4+ |
|
Uses Zig as the linker; a suffix on the target pins the minimum glibc, as in |
Zig, installed from ziglang.org or with |
# Fully static Linux executable (no glibc): a musl target
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# ARM Linux with a container toolchain
cross build --release --target aarch64-unknown-linux-gnu
# ARM Linux that still runs on distributions with an older glibc
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.17
Source: cross and cargo-zigbuild
The cargo-zigbuild README states that -C target-feature=+crt-static is not supported for a glibc target and that
a *-musl target is the way to get a fully static binary, and that the glibc suffix does not exactly reproduce the
behaviour of dynamically linking against that glibc version. Whether a statically linked musl binary is acceptable for
your program depends on what it links (DNS resolution and plugin loading are the usual reasons to stay on glibc). Static
versus dynamic linking, glibc versus musl symbol versions, the Windows CRT choice and macOS universal binaries are all
compared on Native Binaries & Cross-Compilation; signing
and notarizing the result is on Signing, Notarization & OS Trust.
A release profile that suits a command-line tool trades build time for a smaller, faster binary:
[profile.release]
lto = true
codegen-units = 1
strip = true
Source: The Cargo Book: Profiles
strip = true removes symbols and debug information from the executable, so keep an unstripped build (or the debug
symbols) of every release for symbolizing crash reports. Packaging formats and installers are the subject of
Native Packages & Installers.
Pitfalls
-
Reading
std::env::args()directly in a tool that takes paths. It panics on arguments that are not valid Unicode. Parse with clap intoPathBuffields, or useargs_os. -
Using
println!in a tool that is piped. A closed pipe panics with status 101. Write throughwriteln!, propagate the error and treatBrokenPipeas a normal end. -
process::exitin the middle of the program. It skips destructors, so buffered writers are not flushed and temporary files are not deleted. Return anExitCodefrommainand callexitonly in the second-Ctrl+C path. -
Putting colour in output that a program reads. Machine-readable formats such as
--format jsonshould never contain escape codes; keep that rule in the code that chooses the format, not in the caller. -
Environment variables that never reach the program. The
envattribute is available only with clap’senvfeature, and a typo in the variable name silently falls back to the default value. -
Two panic or error hooks.
human_panic::setup_panic!()andcolor_eyre::install()both replace the panic hook; install one. -
Calling
env::set_varto pass configuration to your own code. In the 2024 edition it isunsafebecause of data races with other threads; resolve configuration once into a struct and pass it down. -
Changing the
ProjectDirsnames between releases. The directories move with them, and users appear to lose their data. -
Treating the exit code as a boolean. Document
0,2for usage errors, and any domain codes (3,4,74above) so scripts can depend on them.
A guide to read next
The Command Line Applications in Rust book,
is a step-by-step guide to the same ground, published by the Rust CLI working group: it builds a small grep-like tool and then covers argument parsing,
error handling, output for humans and machines, signal handling, exit codes, configuration and packaging, and links to the
crates used here. This page goes deeper on clap and on the per-platform details; the book is the better first read for a
newcomer to the ecosystem. Testing the tools built this way, including running the real binary and checking its
output, is on Testing Terminal Apps & TUIs. Interactive prompts and
line editing in Rust (dialoguer, inquire, rustyline, reedline) are on
Interactive Prompts & Line Editing, and full-screen interfaces on
Ratatui: Getting Started.