Cómo construir un CLI robusto en Rust con Clap y Tokio

rust Cómo construir un CLI robusto en Rust con Clap y Tokio

Cómo construir un CLI robusto en Rust con Clap y Tokio

En este tutorial construimos un CLI llamado rsfetch que descarga recursos JSON desde una o varias URLs de forma concurrente, los guarda en disco y expone opciones típicas (concurrencia, salida, logging). Usaremos clap para el parsing, tokio para async, reqwest para HTTP, y tracing para telemetría. Te mostraré la estructura de carpetas, el código completo y por qué se hacen ciertas elecciones.

Por qué estas herramientas

  • Clap: ergonomía con derive para opciones complejas y subcomandos.
  • Tokio: runtime asíncrono maduro, necesario para reqwest async y operaciones I/O concurrentes.
  • Reqwest: cliente HTTP ergonomic y compatible con async.
  • Tracing: observabilidad estructurada (más potente que prints).
  • Futures / buffer_unordered: para limitar concurrencia de forma eficiente.

Requisitos

Tener instalado Rust (rustup). Opcional: nightly no es necesario. Ejecuta:

rustup update
rustup default stable

Estructura de proyecto

rsfetch/
├── Cargo.toml
└── src
    ├── main.rs
    ├── args.rs
    ├── fetcher.rs
    └── errors.rs

Cargo.toml

[package]
name = "rsfetch"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
reqwest = { version = "0.11", features = ["json", "gzip", "brotli", "rustls-tls"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
thiserror = "1.0"
futures = "0.3"

src/args.rs

use std::path::PathBuf;
use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(author, version, about = "rsfetch: descarga JSON concurrentemente", long_about = None)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,

    /// Directorio donde guardar resultados (si no se especifica, usa ./output)
    #[arg(short, long, global = true)]
    pub out_dir: Option,

    /// Habilita logging en nivel DEBUG
    #[arg(long, global = true, default_value_t = false)]
    pub debug: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Descarga una o varias URLs
    Fetch {
        /// URLs a descargar
        #[arg(required = true)]
        urls: Vec,

        /// Nivel de concurrencia
        #[arg(short, long, default_value_t = 4)]
        concurrency: usize,

        /// Forzar sobrescritura de archivos
        #[arg(short, long, default_value_t = false)]
        force: bool,
    },
}

src/errors.rs

use thiserror::Error;

#[derive(Error, Debug)]
pub enum RsFetchError {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Invalid URL: {0}")]
    InvalidUrl(String),

    #[error("Other error: {0}")]
    Other(String),
}

src/fetcher.rs

use crate::errors::RsFetchError;
use futures::stream::{self, StreamExt};
use reqwest::Client;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{debug, info};

pub async fn fetch_urls(
    client: &Client,
    urls: Vec,
    concurrency: usize,
    out_dir: &Path,
    force: bool,
) -> Result, RsFetchError> {
    fs::create_dir_all(out_dir).await?;

    let sem = concurrency.max(1);

    let results = stream::iter(urls.into_iter())
        .map(|url| {
            let client = client.clone();
            let out_dir = out_dir.to_path_buf();
            async move {
                let res = fetch_one(&client, url.clone(), &out_dir, force).await;
                (url, res)
            }
        })
        .buffer_unordered(sem)
        .collect::>()
        .await;

    // Transform results, return only successful paths or bubble first error
    let mut paths = Vec::new();
    for (url, r) in results {
        match r {
            Ok(p) => {
                info!(url = %url, path = %p.display(), "fetched");
                paths.push(p);
            }
            Err(e) => {
                debug!(url = %url, error = %format!("{}", e), "failed");
                return Err(e);
            }
        }
    }
    Ok(paths)
}

async fn fetch_one(
    client: &Client,
    url: String,
    out_dir: &Path,
    force: bool,
) -> Result {
    let parsed = reqwest::Url::parse(&url).map_err(|_| RsFetchError::InvalidUrl(url.clone()))?;
    let mut filename = sanitize_filename::sanitize(&format!("{}{}",
        parsed.host_str().unwrap_or("unknown"),
        parsed.path().replace('/', "_"),
    ));
    if filename.is_empty() {
        filename = "index".into();
    }
    filename.push_str(".json");
    let dest = out_dir.join(filename);

    if dest.exists() && !force {
        return Err(RsFetchError::Other(format!("file {} exists", dest.display())));
    }

    let resp = client.get(parsed).send().await?;
    let bytes = resp.bytes().await?;
    fs::write(&dest, &bytes).await?;
    Ok(dest)
}

Nota: usamos sanitize-filename para generar nombres seguros. Añádelo en Cargo.toml si lo deseas: sanitize-filename = "0.5". En este ejemplo asumimos que las respuestas son JSON (pero podrías validar el content-type).

src/main.rs

mod args;
mod errors;
mod fetcher;

use args::{Cli, Commands};
use fetcher::fetch_urls;
use reqwest::Client;
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    // Configura logging
    let filter = if cli.debug { "debug" } else { "info" };
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::new(filter))
        .init();

    let out_dir: PathBuf = cli.out_dir.unwrap_or_else(|| PathBuf::from("./output"));
    let client = Client::builder().build()?;

    match cli.command {
        Commands::Fetch { urls, concurrency, force } => {
            let paths = fetch_urls(&client, urls, concurrency, &out_dir, force).await?;
            println!("Saved {} files to {}", paths.len(), out_dir.display());
        }
    }

    Ok(())
}

Comandos útiles

# Ejecutar (desde la raíz del proyecto)
cargo run -- fetch https://jsonplaceholder.typicode.com/todos/1 https://jsonplaceholder.typicode.com/todos/2 -c 4 -o ./data

# Compilar release
cargo build --release

# Test parsing de args (ejemplo rápido)
cargo run -- fetch https://jsonplaceholder.typicode.com/todos/1

Por qué esta arquitectura

  • Separar args, fetcher y errors facilita pruebas y mantenimiento.
  • Usar buffer_unordered permite limitar concurrencia sin bloquear el runtime.
  • Tracing centralizado permite controlar niveles vía variable de entorno o flag.
  • Retornar errores tipados (thiserror) hace más claro qué falló y facilita mapping a códigos de salida si lo deseas.

Buenas prácticas y extensiones

  • Implementa reintentos con backoff exponencial (crate tokio-retry o futures-retry).
  • Valida content-type y tamaño máximo antes de escribir en disco.
  • Usa un cliente Client reutilizable para aprovechar keep-alive.
  • Gestión de errores: devuelve códigos de salida distintos para distintos tipos de error (IO vs HTTP vs args inválidos).
  • Agrega tests unitarios: parseo de args, comportamiento de naming, mock de HTTP con wiremock o httpmock.
  • Si necesitas cross-compilation: utiliza cross o instala toolchains target y linkers adecuados.

Consejo avanzado: para CLI que escalan a cientos de URLs, introduce cola con límites dinámicos (leaky-bucket) y usa tracing con spans por request para correlación. Evita bloquear el runtime con operaciones sincrónicas; prefiere las versiones async de fs y redes. Si piensas distribuir binarios, crea workflows de CI que compilen para targets comunes y firmarlos.

Advertencia: nunca asumas el tipo de contenido. Si aceptas URLs arbitrarias, valida y limita tamaño de respuesta para evitar ataques de denegación de recurso por descarga masiva.

Siguiente paso: añade un subcomando watch que observe cambios en una lista de URLs y re-descargue solo cuando cambien los ETag/Last-Modified.

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