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.

· 7 min de leitura

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-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çãoCusto
rusqlite + bundledBinário maior (~2 MB a mais) e compile-time mais longo — mas zero dependência de libsqlite3 no sistema
rusqlite sem bundledBinário leve, mas exige libsqlite3 instalado e com versão compatível em toda máquina de destino
sqlx ou sea-ormCompile-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:

AbordagemCusto
anyhow em tudoRápido de escrever, mas Err(anyhow!("task not found")) perde tipagem — o chamador não consegue dar match em variantes específicas
thiserror em tudoEnum 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árioCamada 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 no mainanyhow nã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-orm impõe padrão Active Record. Para schema próprio e queries concentradas em src/db/repo/, rusqlite cru 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: