17 crates de Rust que sustentam um CLI pequeno
Stack real de um CLI ~3k LoC em Rust: argparse, SQLite embutido, migrations, erros diagnósticos, output contract e prompts interativos.
Escrevi um CLI em Rust chamado local-backlog para gerenciar tasks em SQLite
isolado por projeto. O binário tem cerca de 3 mil linhas de código e o
Cargo.toml lista 17 crates. Cada um resolve um problema que eu não queria
reescrever.
Este post é o inventário honesto dessa stack: o papel de cada crate, o trade-off contra a alternativa óbvia, e as armadilhas que já me morderam. Não é tutorial — é o raciocínio por trás das escolhas de um CLI que hoje roda no meu terminal todo dia.
Argparse e CLI
Três crates aqui: clap com a feature derive, clap-verbosity-flag e
clap_complete.
clap é o padrão de facto em Rust. A pergunta real não era “qual argparse”,
era “derive ou builder?”. Com 10+ subcomandos (init, add, list, show,
done, archive, tag, link, projects, doctor), a API builder vira
uma torre de .subcommand().arg().about() encadeados. Com derive, cada
subcomando é uma struct anotada — o tipo é a documentação.
O trade-off: derive força recompilação mais lenta (macros expandem tudo) e erros de compilação menos amigáveis quando você erra uma anotação. Para um CLI pessoal, troco três segundos de compile-time por clareza estrutural sem hesitar.
clap-verbosity-flag dá -v/-vv/-vvv de graça e entrega um filtro
compatível com tracing. clap_complete gera completions para bash, zsh,
fish e PowerShell via backlog completions zsh > _backlog. Zero código
próprio para uma feature que normalmente custa dois fins de semana.
SQLite embutido
rusqlite com a feature bundled e rusqlite_migration. Este é um dos
pontos onde os três custos aparecem com mais força.
| Opção | Custo |
|---|---|
rusqlite + bundled | Binário maior (~2 MB a mais) e compile-time mais longo — mas zero dependência de libsqlite3 no sistema |
rusqlite sem bundled | Binário leve, mas exige libsqlite3 instalado e com versão compatível em toda máquina de destino |
sqlx ou sea-orm | Compile-time-checked queries e async, mas assume Tokio e esquema externo — overkill para schema próprio num CLI síncrono |
Escolhi bundled porque cargo install local-backlog precisa funcionar
numa máquina Ubuntu minimal sem apt install libsqlite3-dev. O CLI é
single-player: o custo de 2 MB no binário é irrelevante.
Migrations via rusqlite_migration com include_str!("../migrations/001_init.sql").
O arquivo .sql é revisável no diff do PR, versionado junto com o código, e
embutido no binário em compile-time. A decisão está registrada em ADR-0003:
migrations são imutáveis após release, correção é migration nova.
A armadilha: rusqlite não roda PRAGMA foreign_keys = ON por padrão. Se
você depende de ON DELETE CASCADE para tenancy, precisa ligar manualmente
em toda conexão aberta. Descobri depois de um teste de integração passar
silenciosamente num cenário que deveria ter cascateado.
Config
figment com features toml e env, mais o crate toml para serialização
explícita do registry de projetos.
O trade-off:
- Env vars puras: simples, mas um CLI com
~/.config/local-backlog/config.toml+ overrides por projeto precisa de merge estruturado — env vars não expressam nested bem. config-rs: mais popular, mas API pesada e histórico de breaking changes entre minors.figment: do time do Rocket, API baseada em “providers” compostos — TOML como base, env como override, defaults em código. Merge previsível.
Para um CLI que lê ~/.config/local-backlog/config.toml e aceita
LOCAL_BACKLOG_DB_PATH como override pontual, figment entrega exatamente
isso em 20 linhas.
Serde
serde com derive e serde_json. Não há muito a dizer — é o backbone de
serialização de Rust. O detalhe: o crate toml também é serde-compatível,
então a mesma struct Config com #[derive(Deserialize)] serve para ler
TOML, JSON e variáveis de ambiente sem código de conversão próprio.
serde_json alimenta o --format=json do output contract (ADR-0004). Todo
comando de leitura emite { "schema_version": 1, "data": [...] } — envelope
estável para consumo por scripts e agentes de IA.
Erros
Duas camadas: thiserror para o domínio (library-style) e miette para o
binário (diagnostic com hint visual).
O trade-off aqui é clássico em Rust:
| Abordagem | Custo |
|---|---|
anyhow em tudo | Rápido de escrever, mas Err(anyhow!("task not found")) perde tipagem — o chamador não consegue dar match em variantes específicas |
thiserror em tudo | Enum de erro tipado em toda camada, mas no binário você acaba só imprimindo o Display — o esforço de tipagem não rende |
thiserror no domínio + miette no binário | Camada de domínio expõe erros tipados (TaskError::NotFound { id }), o main converte para miette::Report e ganha render bonito com source code snippet, hint e URL de help |
Exemplo: quando um insert viola a trigger de tenancy, o erro sobe como
DbError::TenancyViolation { table, project_id }, o handler em main.rs
converte para miette::Report com .help("Use --project=<slug> para mudar de tenant.")
e o usuário vê uma caixa renderizada no terminal, não um stacktrace cru.
Se isso é idiomático Rust ou over-engineering, honestamente não sei — é o
padrão que o ecossistema CLI parece convergir (ver cargo, jj, uv), mas
pode ser peso demais para um binário de 3k LoC.
Observabilidade
tracing + tracing-subscriber com a feature env-filter.
A pergunta óbvia: por que não log? log é o trait histórico do
ecossistema, mas é flat — sem spans, sem structured fields. tracing trata
logs como spans com contexto propagado, que é desproporcional para um CLI
síncrono, exceto por um detalhe: env-filter aceita RUST_LOG=local_backlog::db=trace
e filtra por módulo sem recompilar. log faz o mesmo com
env_logger, mas aí você paga quase o mesmo custo de setup.
O detalhe crítico: tracing-subscriber é configurado para escrever em
stderr, nunca em stdout. Isso vem direto do ADR-0004 — logs em stdout
quebram backlog list | jq. Uma vez que um usuário depende desse pipe,
você não pode mais mover o log de canal sem breaking change.
TTY, cor e prompts
Quatro crates: owo-colors, is-terminal, inquire, dirs.
owo-colors é o sucessor espiritual do colored — mesma API, sem
lock global, compatível com NO_COLOR. Uso pareado com is-terminal:
antes de aplicar cor em qualquer output, checo stdout().is_terminal().
Se for pipe, cor desligada. É o mesmo princípio do output contract —
quem consome a saída num script não quer códigos ANSI no meio.
A armadilha: inquire (prompts interativos tipo “confirma?”, “escolha uma
opção”) escreve por padrão em stdout. Isso quebra backlog list | jq
se o comando eventualmente precisar de confirmação. O fix foi configurar
RenderConfig para rotear em stderr — duas linhas, mas invisível até
o primeiro usuário reportar pipe quebrado.
dirs resolve cross-platform HOME, CONFIG, DATA. No Linux é
~/.config/local-backlog/, no macOS é ~/Library/Application Support/local-backlog/,
no Windows é %APPDATA%\local-backlog\. Reescrever essa lógica à mão é
como reescrever parsing de datas — sempre falta um edge case.
O release profile
No Cargo.toml, o bloco [profile.release] parece detalhe, mas muda a
ordem de grandeza do binário:
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" prioriza tamanho sobre velocidade, lto = true (link-time
optimization) permite inline entre crates, codegen-units = 1 remove
paralelismo de codegen para permitir mais otimização, panic = "abort"
elimina o unwinding table, strip = true tira símbolos de debug.
O custo: compile-time de release passa de ~40s para ~2min. Para um CLI
pessoal que faz cargo install uma vez por mês, é transparente.
O que eu cortei
anyhow: não uso no binário. Quero erros tipados no domínio e render estruturado nomain—anyhownão entrega nenhum dos dois.tokio/ async: o CLI é síncrono. Uma única conexão SQLite, zero I/O concorrente, zero rede. Async seria cerimônia para nenhum benefício.sqlx/sea-orm: compile-time-checked queries exigem DB rodando no build ou schema dumpado;sea-ormimpõe padrão Active Record. Para schema próprio e queries concentradas emsrc/db/repo/,rusqlitecru com helpers é mais direto.
Fechamento
Um CLI de 3 mil linhas com 17 crates não é inchaço — é o ecossistema fazendo o trabalho que eu não queria repetir: argparse, SQLite embutido, migrations versionadas, erros diagnósticos, output contract, prompts cross-platform. Cada crate cobre um problema que, se resolvido à mão, custaria pelo menos um fim de semana e voltaria em forma de bug dois meses depois.
Código em github.com/OliveiraCleidson/local-backlog.
Discussão
Este blog não tem comentários. Para debater o conteúdo: