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.

· 6 min read

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.

OptionCost
rusqlite + bundledLarger binary (~2 MB more) and longer compile time—but zero dependency on libsqlite3 in the system.
rusqlite without bundledLightweight binary, but requires libsqlite3 installed and with a compatible version on every target machine.
sqlx or sea-ormCompile-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:

ApproachCost
anyhow for everythingFast to write, but Err(anyhow!("task not found")) loses typing—the caller can’t match on specific variants.
thiserror for everythingTyped 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 binaryDomain 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 in mainanyhow delivers 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-orm imposes the Active Record pattern. For a custom schema and queries concentrated in src/db/repo/, raw rusqlite with 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.

Code at github.com/OliveiraCleidson/local-backlog.

Discussion

This blog has no comments. To discuss this post: