17 crates de Rust que bancan un CLI chico

Stack real de un CLI de ~3k LoC en Rust: argparse, SQLite embebido, migrations, errores de diagnóstico, output contract y prompts interactivos.

· 7 min de lectura

Escribí un CLI en Rust llamado local-backlog para gestionar tareas en SQLite aislado por proyecto. El binario tiene cerca de 3 mil líneas de código y el Cargo.toml lista 17 crates. Cada uno resuelve un problema que yo no quería volver a programar de cero.

Este post es el inventario honesto de esa stack: el rol de cada crate, el trade-off contra la alternativa obvia y las trampas que ya me mordieron. No es un tutorial — es el razonamiento detrás de las elecciones de un CLI que hoy corre en mi terminal todos los días.

Argparse y CLI

Tres crates acá: clap con la feature derive, clap-verbosity-flag e clap_complete.

clap es el estándar de facto en Rust. La pregunta real no era “¿qué argparse?”, era “¿derive o builder?”. Con más de 10 subcomandos (init, add, list, show, done, archive, tag, link, projects, doctor), la API builder se vuelve una torre de .subcommand().arg().about() encadenados. Con derive, cada subcomando es una struct anotada — el tipo es la documentación.

El trade-off: derive fuerza una recompilación más lenta (las macros expanden todo) y errores de compilación menos amigables cuando le pifiás a una anotación. Para un CLI personal, cambio tres segundos de compilación por claridad estructural sin dudarlo.

clap-verbosity-flag te da el -v/-vv/-vvv gratis y entrega un filtro compatible con tracing. clap_complete genera completions para bash, zsh, fish y PowerShell vía backlog completions zsh > _backlog. Cero código propio para una feature que normalmente te costaría dos fines de semana.

SQLite embebido

rusqlite con la feature bundled y rusqlite_migration. Este es uno de los puntos donde los tres costos aparecen con más fuerza.

OpciónCosto
rusqlite + bundledBinario más grande (~2 MB extra) y compile-time más largo — pero cero dependencia de libsqlite3 en el sistema
rusqlite sin bundledBinario liviano, pero exige libsqlite3 instalado y con versión compatible en cada máquina de destino
sqlx o sea-ormConsultas chequeadas en tiempo de compilación y async, pero asume Tokio y un esquema externo — demasiado para un esquema propio en un CLI sincrónico

Elegí bundled porque cargo install local-backlog tiene que funcionar en una máquina Ubuntu mínima sin tener que hacer un apt install libsqlite3-dev. El CLI es para un solo jugador: el costo de 2 MB en el binario es irrelevante.

Migrations vía rusqlite_migration con include_str!("../migrations/001_init.sql"). El archivo .sql es revisable en el diff del PR, versionado junto con el código y embebido en el binario en tiempo de compilación. La decisión está registrada en el ADR-0003: las migraciones son inmutables después del release; si hay que corregir, se hace una migración nueva.

La trampa: rusqlite no corre PRAGMA foreign_keys = ON por defecto. Si dependés de ON DELETE CASCADE para el tenancy, tenés que activarlo manualmente en cada conexión abierta. Me enteré después de que un test de integración pasó silenciosamente en un escenario que debería haber fallado en cascada.

Config

figment con las features toml y env, más el crate toml para la serialización explícita del registro de proyectos.

El trade-off:

  • Env vars puras: simple, pero un CLI con ~/.config/local-backlog/config.toml + overrides por proyecto necesita un merge estructurado — las env vars no expresan bien lo anidado.
  • config-rs: más popular, pero con una API pesada e historial de breaking changes entre versiones menores.
  • figment: del equipo de Rocket, API basada en “providers” compuestos — TOML como base, env como override, defaults en el código. Merge predecible.

Para un CLI que lee ~/.config/local-backlog/config.toml y acepta LOCAL_BACKLOG_DB_PATH como override puntual, figment entrega exactamente eso en 20 líneas.

Serde

serde con derive y serde_json. No hay mucho que decir — es la columna vertebral de la serialización en Rust. El detalle: el crate toml también es compatible con serde, así que la misma struct Config con #[derive(Deserialize)] sirve para leer TOML, JSON y variables de entorno sin código de conversión propio.

serde_json alimenta el --format=json del output contract (ADR-0004). Todo comando de lectura emite { "schema_version": 1, "data": [...] } — un sobre estable para que lo consuman scripts y agentes de IA.

Errores

Dos capas: thiserror para el dominio (estilo librería) y miette para el binario (diagnóstico con ayuda visual).

El trade-off acá es un clásico en Rust:

EnfoqueCosto
anyhow en todoRápido de escribir, pero Err(anyhow!("task not found")) pierde el tipado — el que llama no puede hacer match en variantes específicas
thiserror en todoEnum de error tipado en cada capa, pero en el binario terminás solo imprimiendo el Display — el esfuerzo del tipado no rinde
thiserror en el dominio + miette en el binarioLa capa de dominio expone errores tipados (TaskError::NotFound { id }), el main convierte a miette::Report y ganás un renderizado lindo con fragmentos de código, sugerencias y URL de ayuda

Ejemplo: cuando un insert viola el trigger de tenancy, el error sube como DbError::TenancyViolation { table, project_id }, el handler en main.rs lo convierte a miette::Report con .help("Usá --project=<slug> para cambiar de tenant.") y el usuario ve una cajita renderizada en la terminal, no un stacktrace crudo.

Si esto es idiomático en Rust o sobre-ingeniería, honestamente no lo sé — es el patrón al que el ecosistema CLI parece converger (ver cargo, jj, uv), pero puede ser mucho peso para un binario de 3k LoC.

Observabilidad

tracing + tracing-subscriber con la feature env-filter.

La pregunta obvia: ¿por qué no log? log es el trait histórico del ecosistema, pero es chato — sin spans, sin campos estructurados. tracing trata a los logs como spans con contexto propagado, que es desproporcionado para un CLI sincrónico, excepto por un detalle: env-filter acepta RUST_LOG=local_backlog::db=trace y filtra por módulo sin recompilar. log hace lo mismo con env_logger, pero ahí pagás casi el mismo costo de setup.

El detalle crítico: tracing-subscriber está configurado para escribir en stderr, nunca en stdout. Esto viene directo del ADR-0004 — los logs en stdout rompen el backlog list | jq. Una vez que un usuario depende de ese pipe, ya no podés mover el log de canal sin un breaking change.

TTY, color y prompts

Cuatro crates: owo-colors, is-terminal, inquire, dirs.

owo-colors es el sucesor espiritual de colored — misma API, sin lock global, compatible con NO_COLOR. Lo uso a la par de is-terminal: antes de aplicar color en cualquier salida, chequeo stdout().is_terminal(). Si es un pipe, el color se apaga. Es el mismo principio del output contract — quien consume la salida en un script no quiere códigos ANSI en el medio.

La trampa: inquire (prompts interactivos tipo “¿confirmás?”, “elegí una opción”) escribe en stdout por defecto. Esto rompe el backlog list | jq si el comando eventualmente necesita confirmación. El fix fue configurar RenderConfig para enrutar a stderr — dos líneas, pero invisible hasta que el primer usuario reportó que el pipe se rompía.

dirs resuelve el HOME, CONFIG y DATA de forma multiplataforma. En Linux es ~/.config/local-backlog/, en macOS es ~/Library/Application Support/local-backlog/, en Windows es %APPDATA%\local-backlog\. Reescribir esta lógica a mano es como reescribir el parsing de fechas — siempre te falta algún edge case.

El perfil de release

En el Cargo.toml, el bloque [profile.release] parece un detalle, pero cambia el orden de magnitud del binario:

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

opt-level = "z" prioriza el tamaño sobre la velocidad, lto = true (link-time optimization) permite el inlining entre crates, codegen-units = 1 elimina el paralelismo del codegen para permitir más optimización, panic = "abort" elimina la tabla de unwinding y strip = true quita los símbolos de debug.

El costo: el tiempo de compilación del release pasa de ~40s a ~2min. Para un CLI personal que hacés un cargo install una vez al mes, es transparente.

Lo que saqué

  • anyhow: no lo uso en el binario. Quiero errores tipados en el dominio y un renderizado estructurado en el mainanyhow no me da ninguna de las dos cosas.
  • tokio / async: el CLI es sincrónico. Una única conexión SQLite, cero I/O concurrente, cero red. El async sería pura ceremonia para ningún beneficio.
  • sqlx / sea-orm: las consultas chequeadas en tiempo de compilación exigen que la DB esté corriendo en el build o un dump del esquema; sea-orm impone el patrón Active Record. Para un esquema propio y consultas concentradas en src/db/repo/, rusqlite crudo con unos helpers es más directo.

Cierre

Un CLI de 3 mil líneas con 17 crates no es hinchazón (bloat) — es el ecosistema haciendo el laburo que yo no quería repetir: argparse, SQLite embebido, migraciones versionadas, errores diagnósticos, output contract, prompts multiplataforma. Cada crate cubre un problema que, si se resolviera a mano, costaría por lo menos un fin de semana y volvería en forma de bug dos meses después.

Código en github.com/OliveiraCleidson/local-backlog.

Discusión

Este blog no tiene comentarios. Para debatir este post: