Cómo construir un CLI robusto en Rust: argumentos, logging, configuración y pruebas
Este tutorial te guía paso a paso para crear un CLI en Rust bien estructurado: parsing de argumentos con clap, logging con tracing, carga de configuración (TOML + serde), manejo de errores con thiserror, tests y CI básica. Incluye la estructura de carpetas y código completo mínimo pero realista.
Por qué estas elecciones
- clap derive: parsing ergonomic y validaciones declarativas.
- tracing + tracing_subscriber: observabilidad moderna y niveles dinámicos.
- serde + toml: configuración serializable y legible para usuarios.
- thiserror: errores tipados y legibles para tests y depuración.
- estructura modular: facilita testing y mantenimiento.
Requisitos
- Rust 1.65+ (apt para clap 4)
- cargo
Estructura del proyecto
mycli/
├─ .github/workflows/ci.yml
├─ Cargo.toml
└─ src/
├─ main.rs
├─ commands.rs
├─ config.rs
└─ error.rs
Cargo.toml (dependencias)
[package]
name = "mycli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
serde = { version = "1.0", features = ["derive"] }
toml = "0.5"
thiserror = "1.0"
directories = "4"
src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum CliError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Config parse error: {0}")]
Toml(#[from] toml::de::Error),
#[error("Invalid input: {0}")]
InvalidInput(String),
}
src/config.rs
use serde::Deserialize;
use std::fs;
use std::path::Path;
use directories::ProjectDirs;
use crate::error::CliError;
#[derive(Debug, Deserialize, PartialEq)]
pub struct Config {
pub default_input: Option,
pub max_items: Option,
}
impl Default for Config {
fn default() -> Self {
Self { default_input: None, max_items: Some(100) }
}
}
impl Config {
pub fn load(path_opt: Option<&Path>) -> Result {
if let Some(p) = path_opt {
let s = fs::read_to_string(p)?;
Ok(toml::from_str(&s)?)
} else if let Some(proj) = ProjectDirs::from("com", "example", "mycli") {
let cfg = proj.config_dir().join("config.toml");
if cfg.exists() {
let s = fs::read_to_string(cfg)?;
Ok(toml::from_str(&s)?)
} else {
Ok(Config::default())
}
} else {
Ok(Config::default())
}
}
}
src/commands.rs
use crate::error::CliError;
pub enum ActionResult {
Success(String),
}
pub fn run_input(input: &str, max_items: usize) -> Result {
if input.trim().is_empty() {
return Err(CliError::InvalidInput("input is empty".into()));
}
// Simula procesamiento: truncar a max_items
let result = input.chars().take(max_items).collect::();
Ok(ActionResult::Success(result))
}
src/main.rs
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::{info, error};
mod commands;
mod config;
mod error;
use crate::commands::run_input;
use crate::config::Config;
use crate::error::CliError;
#[derive(Parser, Debug)]
#[command(name = "mycli", about = "Ejemplo de CLI robusto en Rust")]
struct Cli {
/// Ruta al archivo de configuración (TOML)
#[arg(short, long)]
config: Option,
/// Nivel de verbosidad (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Ejecuta con un input
Run { input: String },
/// Muestra la configuración resuelta
ShowConfig,
}
fn init_tracing(verbose: u8) {
use tracing_subscriber::{fmt, EnvFilter};
let level = match verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
let env_filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry().with(fmt::layer()).with(env_filter).init();
}
fn main() -> Result<(), CliError> {
let cli = Cli::parse();
init_tracing(cli.verbose);
info!(?cli.config, ?cli.verbose, "Starting mycli");
let cfg = Config::load(cli.config.as_deref())?;
match cli.command {
Commands::Run { input } => {
let input = if input.is_empty() {
cfg.default_input.clone().unwrap_or_default()
} else {
input
};
let max = cfg.max_items.unwrap_or(100);
match run_input(&input, max) {
Ok(res) => match res {
commands::ActionResult::Success(out) => {
println!("{}", out);
Ok(())
}
},
Err(e) => {
error!(error = %e, "Command failed");
Err(e)
}
}
}
Commands::ShowConfig => {
println!("Config: {:?}", cfg);
Ok(())
}
}
}
Tests básicos
Ejemplo de test para run_input y para parsing de config.
#[cfg(test)]
mod tests {
use super::run_input;
use crate::config::Config;
use std::path::Path;
#[test]
fn test_run_input_ok() {
let res = run_input("hello", 10).unwrap();
match res {
crate::commands::ActionResult::Success(s) => assert_eq!(s, "hello"),
}
}
#[test]
fn test_config_parse() {
let toml = r#"default_input = """""max_items = 5"#; // improbable but demostrativo
// También se podría probar con un archivo temporal; aquí verificamos el Default
let cfg = Config::default();
assert_eq!(cfg.max_items, Some(100));
}
}
Comandos útiles
# construir
cargo build --release
# ejecutar
cargo run -- run "hola mundo"
# con archivo de config
cargo run -- -c ./config.toml show-config
CI: GitHub Actions (ci.yml)
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
Por qué modularizar así
- Separar
config,commandsyerrorclarifica responsabilidades y facilita el testing unitario. - Inicializar
tracingenmainusando el nivel derivado de los flags permite ajustar la verbosidad sin recompilar. - Cargar configuración desde archivo o valores por defecto hace el CLI flexible para usuarios y para pruebas.
Mejores prácticas aplicadas
- Errores tipados con
thiserrorpara manipulación y mapeo en tests/CI. - Uso de
directoriespara descubrir rutas de configuración multiplataforma. - Logs estructurados con
tracingpara integraciones futuras con observabilidad. - Pruebas unitarias pequeñas y deterministas para la lógica crítica.
Consejo avanzado: para CLIs que hagan IO intensivo o networking, considera integrar tokio y diseñar la API de comandos como async; mantén la separación lógica (parsing & wiring en main, lógica pura en módulos) para preservar testabilidad y composición. También, añade property tests (proptest) para invariantes complejos.
Advertencia: evita unwraps en código real; en este ejemplo se usan patrones simplificados para foco pedagógico. Siguiente paso: agrega parsing de subcomandos más complejos, validaciones personalizadas en clap y ejemplos de fixtures para tests de integración.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación