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,fetcheryerrorsfacilita pruebas y mantenimiento. - Usar
buffer_unorderedpermite 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-retryofutures-retry). - Valida
content-typey tamaño máximo antes de escribir en disco. - Usa un cliente
Clientreutilizable 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
wiremockohttpmock. - Si necesitas cross-compilation: utiliza
crosso 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación