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

std::env::args_os

clap

Help, --version, validation, subcommands, environment fallbacks and shell completions generated from types

Application errors

Box<dyn Error>

anyhow

Context messages on every layer and a printable cause chain

Library or domain errors

hand-written Error impls

thiserror

Display, source and From generated from attributes, so callers can match on variants

Developer-facing reports

the default panic hook

color-eyre

Coloured error reports with spans and backtraces

User-facing panics

the default panic hook

human-panic

A short message and a report file instead of a raw backtrace

Ctrl+C and termination

none

ctrlc, signal-hook

Safe handlers on every OS (ctrlc) or the full Unix signal set (signal-hook)

Terminal detection

std::io::IsTerminal

none needed

Stable since Rust 1.70; use it before the tool prints colour, progress bars or prompts

Colour

none

anstream, owo-colors

Escape codes that are stripped when the output is not a terminal

Configuration directories

env::home_dir (limited)

directories

XDG on Linux, Library on macOS, Known Folders on Windows

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,
}

What each attribute does:

  • version with no value reads the crate version from Cargo.toml; about with no value reads the crate description. Without long_about = None, a doc comment that contains a blank line becomes a separate long help text shown only for --help. propagate_version makes every subcommand answer --version as well.

  • global = true makes an argument valid before or after any subcommand, so shelf list --format json and shelf --format json list both work.

  • The env attribute, given the name SHELF_DATA_DIR, makes that environment variable a fallback for the option (the env feature must be enabled). An env attribute 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::Count turns repeated short flags into a counter, so -vv gives verbose == 2. A bool field gets SetTrue automatically.

  • The doc comment of each field becomes its --help text; the doc comment of a ValueEnum variant becomes the help text of that possible value.

  • value_enum is required on a field whose type derives ValueEnum. default_value_t needs the type to round-trip through its textual form, which a ValueEnum does 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()
        }
    }
}

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

thiserror

The error type of a module that callers may match on

#[derive(Error)] on an enum; #[error("...")] writes Display, #[source] and #[from] write source and From

anyhow

The application layer: main, run, and anything that only needs to report

anyhow::Result<T>, .context(...), .with_context(|| ...), bail!, ensure!; downcast back to the original type

color-eyre

Tools whose users are developers

A fork of eyre with a panic and error hook that prints coloured reports with backtraces and suggestions

human-panic

Tools whose users are not developers

human_panic::setup_panic!() replaces the panic message with a short note and a report file

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

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

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 SIGABRT, SIGBREAK, SIGFPE, SIGILL, SIGINT, SIGSEGV and SIGTERM, based on the C runtime’s signal)

What it handles

Ctrl+C: SIGINT on Unix, CTRL_C_EVENT or CTRL_BREAK_EVENT on Windows; with the termination feature, also SIGTERM and SIGHUP on Unix

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 flag, iterator and pipe modules

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

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

/home/alice/.config/barapp (from $XDG_CONFIG_HOME or $HOME/.config; the name is lower-cased and spaces are removed)

macOS

/Users/Alice/Library/Application Support/com.Foo-Corp.Bar-App

Windows

C:\Users\Alice\AppData\Roaming\Foo Corp\Bar App\config

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

args_os gives raw bytes; the shell expands globs such as *.csv before shelf starts

Same as Linux

args_os is decoded from the UTF-16 command line; neither cmd.exe nor PowerShell expands globs for a native program, so shelf import *.csv receives the literal pattern and the program must expand it

Ctrl+C

SIGINT to the foreground process group

Same as Linux

A console control event; ctrlc maps it, signal-hook covers only a subset

Termination request

SIGTERM, SIGHUP

Same as Linux

No SIGTERM from the system; TerminateProcess cannot be caught, console close events need the console control handler

Closed pipe

SIGPIPE ignored by the Rust runtime; BrokenPipe error

Same as Linux

Error return, mapped to BrokenPipe

Colour

ANSI sequences on terminals

ANSI sequences on terminals

ANSI sequences in modern terminals; anstream adapts for the legacy console

Per-user directories

XDG (~/.config)

~/Library/Application Support

%APPDATA% (roaming) and %LOCALAPPDATA%

Executable name

shelf

shelf

shelf.exe; a .bat or .cmd file is run by cmd.exe

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

cargo build --target ... after rustup target add ...

Builds for any target whose linker you provide; the simplest route when host and target share an OS family, or for x86_64-apple-darwin and aarch64-apple-darwin on a Mac

rustup and the target’s linker

cross

Runs cargo inside a prepared container image that holds the cross toolchain and libraries (cross build --release --target aarch64-unknown-linux-gnu)

Docker or Podman; its README documents Docker 20.10+ or Podman 3.4+

cargo-zigbuild

Uses Zig as the linker; a suffix on the target pins the minimum glibc, as in cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.17

Zig, installed from ziglang.org or with pip3 install ziglang

# 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

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 into PathBuf fields, or use args_os.

  • Using println! in a tool that is piped. A closed pipe panics with status 101. Write through writeln!, propagate the error and treat BrokenPipe as a normal end.

  • process::exit in the middle of the program. It skips destructors, so buffered writers are not flushed and temporary files are not deleted. Return an ExitCode from main and call exit only in the second-Ctrl+C path.

  • Putting colour in output that a program reads. Machine-readable formats such as --format json should 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 env attribute is available only with clap’s env feature, and a typo in the variable name silently falls back to the default value.

  • Two panic or error hooks. human_panic::setup_panic!() and color_eyre::install() both replace the panic hook; install one.

  • Calling env::set_var to pass configuration to your own code. In the 2024 edition it is unsafe because of data races with other threads; resolve configuration once into a struct and pass it down.

  • Changing the ProjectDirs names between releases. The directories move with them, and users appear to lose their data.

  • Treating the exit code as a boolean. Document 0, 2 for usage errors, and any domain codes (3, 4, 74 above) 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.