Cómo construir un CLI robusto en Rust: argumentos, logging, configuración y pruebas

rust Cómo construir un CLI robusto en Rust: argumentos, logging, configuración y pruebas

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, commands y error clarifica responsabilidades y facilita el testing unitario.
  • Inicializar tracing en main usando 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 thiserror para manipulación y mapeo en tests/CI.
  • Uso de directories para descubrir rutas de configuración multiplataforma.
  • Logs estructurados con tracing para 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión