17 Rust Crates Powering a Small CLI
Real-world stack of a ~3k LoC CLI in Rust: argparse, embedded SQLite, migrations, diagnostic errors, output contract, and interactive prompts.
I wrote a Rust CLI called local-backlog to manage tasks in a per-project isolated SQLite database. The binary has about 3,000 lines of code, and the Cargo.toml lists 17 crates. Each one solves a problem I didn’t want to reinvent.
This post is an honest inventory of that stack: each crate’s role, the trade-off against obvious alternatives, and the traps that have already caught me. It’s not a tutorial—it’s the reasoning behind the choices for a CLI that runs in my terminal every day.
Argparse and CLI
Three crates here: clap with the derive feature, clap-verbosity-flag, and clap_complete.
clap is the de facto standard in Rust. The real question wasn’t “which argparse,” but “derive or builder?” With 10+ subcommands (init, add, list, show, done, archive, tag, link, projects, doctor), the builder API turns into a tower of chained .subcommand().arg().about() calls. With derive, each subcommand is an annotated struct—the type is the documentation.
The trade-off: derive forces slower recompilation (macros expand everything) and less friendly compilation errors when you mess up an annotation. For a personal CLI, I’ll trade three seconds of compile time for structural clarity without hesitation.
clap-verbosity-flag provides -v/-vv/-vvv for free and returns a filter compatible with tracing. clap_complete generates completions for bash, zsh, fish, and PowerShell via backlog completions zsh > _backlog. Zero custom code for a feature that usually costs two weekends.
Embedded SQLite
rusqlite with the bundled feature and rusqlite_migration. This is one of the points where the three costs appear most strongly.
| Option | Cost |
|---|---|
rusqlite + bundled | Larger binary (~2 MB more) and longer compile time—but zero dependency on libsqlite3 in the system. |
rusqlite without bundled | Lightweight binary, but requires libsqlite3 installed and with a compatible version on every target machine. |
sqlx or sea-orm | Compile-time-checked queries and async, but assumes Tokio and an external schema—overkill for a custom schema in a synchronous CLI. |
I chose bundled because cargo install local-backlog needs to work on a minimal Ubuntu machine without apt install libsqlite3-dev. The CLI is single-player: the 2 MB cost in the binary is irrelevant.
Migrations via rusqlite_migration with include_str!("../migrations/001_init.sql"). The .sql file is reviewable in PR diffs, versioned alongside the code, and embedded in the binary at compile time. The decision is recorded in ADR-0003: migrations are immutable after release; fixes require a new migration.
The trap: rusqlite doesn’t run PRAGMA foreign_keys = ON by default. If you depend on ON DELETE CASCADE for tenancy, you need to enable it manually for every open connection. I discovered this after an integration test passed silently in a scenario that should have cascaded.
Config
figment with toml and env features, plus the toml crate for explicit project registry serialization.
The trade-off:
- Pure env vars: Simple, but a CLI with
~/.config/local-backlog/config.toml+ per-project overrides needs structured merging—env vars don’t express nesting well. config-rs: More popular, but heavy API and a history of breaking changes between minors.figment: From the Rocket team, an API based on composed “providers”—TOML as base, env as override, defaults in code. Predictable merging.
For a CLI that reads ~/.config/local-backlog/config.toml and accepts LOCAL_BACKLOG_DB_PATH as a one-off override, figment delivers exactly that in 20 lines.
Serde
serde with derive and serde_json. Not much to say—it’s the backbone of Rust serialization. The detail: the toml crate is also serde-compatible, so the same Config struct with #[derive(Deserialize)] serves to read TOML, JSON, and environment variables without custom conversion code.
serde_json powers the --format=json of the output contract (ADR-0004). Every read command emits { "schema_version": 1, "data": [...] }—a stable envelope for consumption by scripts and AI agents.
Errors
Two layers: thiserror for the domain (library-style) and miette for the binary (diagnostics with visual hints).
The trade-off here is a classic in Rust:
| Approach | Cost |
|---|---|
anyhow for everything | Fast to write, but Err(anyhow!("task not found")) loses typing—the caller can’t match on specific variants. |
thiserror for everything | Typed error enum at every layer, but in the binary, you end up just printing the Display—the typing effort doesn’t pay off. |
thiserror domain + miette binary | Domain layer exposes typed errors (TaskError::NotFound { id }), main converts to miette::Report and gets beautiful rendering with source code snippets, hints, and help URLs. |
Example: when an insert violates a tenancy trigger, the error climbs up as DbError::TenancyViolation { table, project_id }, the handler in main.rs converts it to miette::Report with .help("Use --project=<slug> to change tenants."), and the user sees a rendered box in the terminal, not a raw stacktrace.
Whether this is idiomatic Rust or over-engineering, honestly, I don’t know—it’s the pattern the CLI ecosystem seems to be converging on (see cargo, jj, uv), but it might be too much weight for a 3k LoC binary.
Observability
tracing + tracing-subscriber with the env-filter feature.
The obvious question: why not log? log is the historical trait of the ecosystem, but it’s flat—no spans, no structured fields. tracing treats logs as spans with propagated context, which is disproportionate for a synchronous CLI, except for one detail: env-filter accepts RUST_LOG=local_backlog::db=trace and filters by module without recompiling. log does the same with env_logger, but then you pay almost the same setup cost.
The critical detail: tracing-subscriber is configured to write to stderr, never to stdout. This comes directly from ADR-0004—logs in stdout break backlog list | jq. Once a user depends on that pipe, you can’t move the log channel without a breaking change.
TTY, Color, and Prompts
Four crates: owo-colors, is-terminal, inquire, dirs.
owo-colors is the spiritual successor to colored—same API, no global lock, compatible with NO_COLOR. Used in tandem with is-terminal: before applying color to any output, I check stdout().is_terminal(). If it’s a pipe, color is disabled. This follows the same output contract principle—anyone consuming the output in a script doesn’t want ANSI codes in the middle.
The trap: inquire (interactive prompts like “confirm?”, “choose an option”) writes to stdout by default. This breaks backlog list | jq if the command eventually needs confirmation. The fix was configuring RenderConfig to route to stderr—two lines, but invisible until the first user reports a broken pipe.
dirs handles cross-platform HOME, CONFIG, and DATA. On Linux, it’s ~/.config/local-backlog/, on macOS, it’s ~/Library/Application Support/local-backlog/, and on Windows, it’s %APPDATA%\local-backlog\. Rewriting this logic by hand is like rewriting date parsing—you’ll always miss an edge case.
The Release Profile
In Cargo.toml, the [profile.release] block seems like a detail, but it changes the order of magnitude of the binary:
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" prioritizes size over speed, lto = true (link-time optimization) allows inlining across crates, codegen-units = 1 removes codegen parallelism to allow more optimization, panic = "abort" eliminates the unwinding table, and strip = true removes debug symbols.
The cost: release compile time goes from ~40s to ~2min. For a personal CLI that I cargo install once a month, it’s transparent.
What I Cut
anyhow: I don’t use it in the binary. I want typed errors in the domain and structured rendering inmain—anyhowdelivers neither.tokio/ async: The CLI is synchronous. A single SQLite connection, zero concurrent I/O, zero network. Async would be ceremony for no benefit.sqlx/sea-orm: Compile-time-checked queries require a running DB during build or a dumped schema;sea-ormimposes the Active Record pattern. For a custom schema and queries concentrated insrc/db/repo/, rawrusqlitewith helpers is more direct.
Closing
A 3,000-line CLI with 17 crates isn’t bloat—it’s the ecosystem doing the work I didn’t want to repeat: argparse, embedded SQLite, versioned migrations, diagnostic errors, output contract, cross-platform prompts. Each crate covers a problem that, if solved by hand, would cost at least a weekend and return as a bug two months later.
Discussion
This blog has no comments. To discuss this post: