Una herramienta de línea de órdenes completa en Rust

Este último ejemplo cose varios de los anteriores en algo que se puede usar de verdad: una herramienta que lee un CSV, admite subórdenes y da mensajes de error útiles. Es el patrón de casi cualquier utilidad de línea de órdenes escrita en Rust.

Las dependencias

cargo add clap --features derive
cargo add csv
cargo add serde --features derive
cargo add anyhow
[dependencies]
anyhow = "1.0.104"
clap = { version = "4.6.7", features = ["derive"] }
csv = "1.4.0"
serde = { version = "1.0.229", features = ["derive"] }

La herramienta entera

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::path::PathBuf;

#[derive(Parser)]
#[command(version, about = "Consulta un inventario en CSV")]
struct Args {
    /// Archivo CSV de entrada
    #[arg(short, long, default_value = "inventario.csv")]
    archivo: PathBuf,

    #[command(subcommand)]
    orden: Orden,
}

#[derive(Subcommand)]
enum Orden {
    /// Lista los artículos
    Listar {
        /// Solo los que tengan al menos estas unidades
        #[arg(short, long, default_value_t = 0)]
        minimo: u32,
    },
    /// Muestra el valor total del inventario
    Total,
}

#[derive(Debug, Deserialize)]
struct Articulo {
    nombre: String,
    precio: f64,
    unidades: u32,
}

fn leer(ruta: &PathBuf) -> Result<Vec<Articulo>> {
    let mut lector = csv::Reader::from_path(ruta)
        .with_context(|| format!("no se pudo abrir {}", ruta.display()))?;
    lector
        .deserialize()
        .collect::<Result<Vec<Articulo>, _>>()
        .context("el CSV tiene filas con formato incorrecto")
}

fn main() -> Result<()> {
    let args = Args::parse();
    let articulos = leer(&args.archivo)?;

    match args.orden {
        Orden::Listar { minimo } => {
            for a in articulos.iter().filter(|a| a.unidades >= minimo) {
                println!("{:<12} {:>6.2} x {:>4} = {:>9.2}", a.nombre, a.precio, a.unidades, a.precio * a.unidades as f64);
            }
        }
        Orden::Total => {
            let total: f64 = articulos.iter().map(|a| a.precio * a.unidades as f64).sum();
            println!("{} artículos, valor total {:.2}", articulos.len(), total);
        }
    }
    Ok(())
}
cargo run -- listartornillos      1.50 x  120 =    180.00
tuercas        0.80 x   80 =     64.00
arandelas      0.25 x   45 =     11.25
clavos         0.10 x  500 =     50.00
cargo run -- listar --minimo 100tornillos      1.50 x  120 =    180.00
clavos         0.10 x  500 =     50.00
cargo run -- total4 artículos, valor total 305.25

Lo que aporta cada pieza

Las subórdenes son un enum, y cada variante lleva sus propios argumentos. Eso significa que minimo solo existe dentro de Listar: no hay forma de leerlo al ejecutar total, porque el tipo no lo permite. Es el conjunto cerrado de la lección de structs y enums puesto a trabajar.

anyhow resuelve el problema de que las funciones devuelven errores de tipos distintos: uno de archivo, otro del analizador de CSV. En una aplicación, a diferencia de una biblioteca, casi nunca hace falta distinguirlos por tipo, basta con propagarlos y contar bien qué pasó. Result<T> de anyhow acepta cualquiera de ellos.

Por qué importa .context()

Esa es la pieza que convierte un error inútil en uno que resuelve el problema. Con un archivo que no existe:

cargo run -- --archivo nada.csv totalError: no se pudo abrir nada.csv

Caused by:
    No such file or directory (os error 2)

Sin el with_context, el usuario solo vería «No such file or directory», sin saber qué archivo. Con él, el mensaje dice qué se intentaba hacer y debajo la causa técnica. Esa cadena se puede encadenar en varios niveles, y cada capa añade su parte.

anyhow para aplicaciones, thiserror para bibliotecas. La distinción es la de la lección de manejo de errores: en una aplicación el error acaba en la pantalla de una persona y lo que importa es el mensaje; en una biblioteca, quien la usa necesita distinguir un fallo de otro para reaccionar distinto, y ahí hace falta un enum propio, que thiserror ayuda a escribir.
Para distribuirla, compílala con --release. cargo build --release deja un ejecutable en target/release/ que no depende de nada instalado: se copia y funciona. Es una de las razones por las que Rust se ha vuelto popular para herramientas de línea de órdenes, junto con que el binario arranca al instante.

El código de esta página se compiló y ejecutó con rustc 1.96.1 antes de publicarla, con las tres órdenes y con un archivo inexistente; todas las salidas, incluida la cadena de errores, están copiadas de la ejecución real. Documentación oficial: clap y anyhow.