The smiling.dev piece about rewriting a CLI tool from Python to Rust ran through Lobsters recently and generated the usual comments about startup time and binary distribution. Those arguments are sound and well-documented. What gets less coverage is the development experience side of the equation: how the Rust toolchain changes not just the output artifact but the way you build the tool in the first place.
The Type-Safe CLI Definition
The central argument parsing library in Rust is clap, now at version 4.x. Its derive API lets you define a CLI’s complete interface as a Rust struct:
use clap::Parser;
#[derive(Parser)]
#[command(name = "archive-tool", version, about = "Compress and manage archives")]
struct Cli {
#[arg(short, long)]
verbose: bool,
#[arg(short, long, value_enum, default_value_t = Format::Gzip)]
format: Format,
#[arg(required = true)]
files: Vec<PathBuf>,
}
#[derive(clap::ValueEnum, Clone)]
enum Format {
Gzip,
Zstd,
Lz4,
}
fn main() {
let cli = Cli::parse();
// cli.format is already a Format enum, not a string
}
The format field arrives in your application as a Format variant, not as a string you need to validate and match against. If you add a new variant to Format later and forget to handle it in a match, the compiler rejects the build. The --help output is generated from the struct and stays synchronized with the implementation without any effort. Shell completions for bash, zsh, fish, and PowerShell are generated by the companion clap_complete crate from the same definition.
Compare this to the Click equivalent in Python:
import click
@click.command()
@click.option("--verbose", "-v", is_flag=True)
@click.option("--format", "-f",
type=click.Choice(["gzip", "zstd", "lz4"]),
default="gzip")
@click.argument("files", nargs=-1, required=True)
def main(verbose: bool, format: str, files: tuple):
# format is a str; add "brotli" to Choice and forget to handle it,
# and nothing catches this until runtime
pass
Click is a well-designed library and its API is pleasant to write. The format argument has the right annotation for a human reading the code, but the runtime value is still a string. The alignment between option definition and application logic is maintained by the developer, not the compiler. Typer improves on this by leaning into Python’s type annotation system, but validation still happens at runtime.
For larger CLI tools, this difference compounds during refactoring. Rename a subcommand, add a required flag, change an enum variant: in Rust, the compiler identifies every call site that needs updating. In Python, test coverage determines what you catch.
The Supporting Ecosystem
Argument parsing is one part of a CLI tool’s surface area. The Rust ecosystem has mature libraries for the rest.
indicatif handles progress bars and spinners. It manages terminal width, updates without flickering, and supports multi-bar layouts for parallel work:
use indicatif::{ProgressBar, ProgressStyle};
let pb = ProgressBar::new(files.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40}] {pos}/{len} {msg}")?
);
for file in &files {
pb.set_message(file.display().to_string());
process(file)?;
pb.inc(1);
}
pb.finish_with_message("done");
Python has tqdm, which is similarly capable. For a long-running batch job where progress display is the point, the interpreter overhead is irrelevant. For a tool invoked once per file in a shell loop, every millisecond of startup latency multiplies.
dialoguer handles interactive prompts: confirmations, select menus, password inputs. console handles terminal colors and cursor control, with TTY detection that works reliably across platforms. assert_cmd provides a testing API that runs the compiled binary end-to-end and lets you assert on stdout, stderr, and exit codes:
use assert_cmd::Command;
use predicates::str::contains;
#[test]
fn test_version_flag() {
Command::cargo_bin("archive-tool")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(contains("1.0.0"));
}
The Python equivalent, Click’s CliRunner, invokes the Click entry point in-process rather than spawning the binary. This is fine for testing the command logic, but it does not catch packaging problems or platform-specific environment interactions.
Cargo Versus Python’s Packaging Stack
Python’s packaging tooling has accumulated layers over the years: pip, virtualenv, venv, conda, pipenv, poetry, hatch. Each exists because the prior approach left real problems unsolved. The result is an ecosystem where the answer to “how do I install this” varies by context and where reproducible builds required years of additional tooling to approach anything reliable.
Cargo ships with Rust and handles dependency resolution, building, testing, benchmarking, documentation generation, and publishing to crates.io. The Cargo.lock file pins exact versions for reproducible builds with consistent semantics across all environments. There is no separate locking tool, no ecosystem split between competing philosophies about how lock files should work.
For CLI distribution specifically, this matters at the end of the pipeline. cargo build --release produces one binary containing the application and all its dependencies. On Linux, compiling against the musl target produces a statically linked binary with no runtime dependencies:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# runs on any Linux regardless of glibc version
cargo-dist extends this into a full release pipeline, handling cross-compilation for macOS, Linux, and Windows and creating GitHub Release artifacts from a few lines of Cargo.toml configuration. A Python tool aiming for comparable platform coverage needs platform-specific CI runners, platform-specific packaging steps, and either PyInstaller (with its 30-80 MB output and antivirus false positive rate on Windows) or the requirement that users have Python installed and know how to manage it.
What the Ecosystem Shift Shows
The displacement of Python-based developer tools by Rust equivalents follows a consistent pattern. Starship replaced Powerline as the recommended shell prompt for substantive reasons: Powerline requires a Python daemon that adds 100-300ms to each prompt render, where Starship completes the same work in under 10ms. zoxide replaced autojump for directory jumping, removing a Python invocation that fired on every j command. tokei replaced cloc, a Perl script, for code statistics. hyperfine, now the standard tool for CLI benchmarking, is written in Rust and frequently used to measure other Rust tools.
These tools became defaults because the originals had startup latency that was perceptible in the contexts where they are used, and distributing them to users without the relevant runtime installed was genuinely awkward. Rust solved both problems in the same step.
The Upfront Cost
The smiling.dev account describes a real trade-off: Rust requires more upfront investment. Ownership and borrowing take time to internalize. A clean build of a project using clap can take 30-60 seconds. The development iteration cycle is slower than running a Python script directly.
These costs are front-loaded. Once the borrowing model is understood, it stops blocking progress. Incremental builds are fast. And the output is a tool that starts in 2ms, ships as a single file, and has a type-checked interface that the compiler enforces through refactors.
Python remains the right choice for tools that only run in controlled environments where the interpreter is already present, for rapid prototyping where iteration speed matters more than the artifact, and for tools that rely on the Python data science or ML ecosystem. For CLI tools intended for distribution to users across platforms, the Rust toolchain has matured to the point where the learning investment pays back relatively quickly, and the smiling.dev experience is representative of a conclusion many developers reach independently once they ship the first Rust binary.