Tu primer script en Rust para Linuxeros

Vistas: 1

En el capítulo anterior creaste tu primer proyecto Rust y ejecutaste un «Hola mundo». Ahora toca escribir algo que realmente sirva, un script de línea de comandos que lea argumentos, procese entrada estándar, escriba resultados formateados y gestione errores como Dios manda. Vamos lo que haces normalmente en cualquier script, ya sea en Bash, Fish o Python, por ponerte algunos ejemplos. Y como vas a ver a continuación, no hay mucha diferencia entre lo que hacías anteriormente a lo que haces con Rust, es tremendamente parecido. Con lo que si ya estas acostumbrado a tus scripts en Bash o Python, dar el salto a Rust, seguro que no te va a representar, ningún esfuerzo.

Como vas a ver a lo largo de este capítulo, y en los siguientes capítulos de Rust para Linuxeros, se trata de un tutorial eminentemente práctico, con lo que te recomiendo que lo hagas directamente delante de tu PC, para ir ejecutando tu mismo cada una de las operaciones que vamos haciendo.

Empezamos por los argumentos

Si vienes de Bash, esto es lo que haces constantemente:

#!/bin/bash
echo "Argumentos: $#"
while IFS= read -r linea; do
    echo "Leído: $linea"
done
exit 0

Pues bien, en Rust se hace igual de directo, pero con tipado fuerte, sin sorpresas en runtime y compilando a un binario que funciona en cualquier Linux. En Rust, std::env::args() devuelve un iterador con los argumentos. El primero (índice 0) es siempre el nombre del ejecutable.

Crea un proyecto nuevo para este capítulo:

cargo new crustaceo-args
cd crustaceo-args

Edita src/main.rs:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    println!("Número de argumentos: {}", args.len() - 1);

    for (i, arg) in args.iter().enumerate() {
        if i == 0 {
            println!("[0] Ejecutable: {}", arg);
        } else {
            println!("[{}] Argumento: {}", i, arg);
        }
    }
}

Compila y prueba:

cargo run -- hola mundo 42
#    Compiling crustaceo-args v0.1.0
#     Finished `dev` profile [unoptimized + debuginfo]
#      Running `target/debug/crustaceo-args hola mundo 42`
# Número de argumentos: 3
# [0] Ejecutable: target/debug/crustaceo-args
# [1] Argumento: hola
# [2] Argumento: mundo
# [3] Argumento: 42

El -- separa los argumentos de cargo de los argumentos de tu programa. Cuando ejecutes el binario directamente, no hace falta:

./target/debug/crustaceo-args hola mundo 42

Fíjate en env::args().collect(). collect() convierte el iterador en un Vec<String>. Es la forma más directa de capturar todos los argumentos de golpe. Si solo te interesa un argumento concreto, puedes acceder por índice:

let args: Vec<String> = env::args().collect();

if args.len() > 1 {
    let nombre = &args[1];
    println!("Hola, {}!", nombre);
} else {
    eprintln!("Uso: {} <nombre>", args[0]);
    std::process::exit(1);
}

Pero ojo: acceder con args[1] sin comprobar args.len() antes paniquea en runtime si no hay argumentos suficientes. Más adelante veremos clap y otras crates para hacer esto mejor, pero para scripts pequeños, env::args() va sobrado.

Argumentos posicionales vs flags

Los scripts de sysadmin suelen aceptar dos tipos de argumentos:

  • Posicionales: el orden importa (archivo.txt, usuario)
  • Flags: modifican el comportamiento (--verbose, -v)

Con env::args() puedes gestionar ambos manualmente:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut verbose = false;
    let mut archivos: Vec<String> = Vec::new();

    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "-v" | "--verbose" => verbose = true,
            "-h" | "--help" => {
                println!("Uso: {} [-v] <archivo>...", args[0]);
                std::process::exit(0);
            }
            arg => archivos.push(arg.to_string()),
        }
        i += 1;
    }

    if verbose {
        println!("Modo verbose activado");
    }

    if archivos.is_empty() {
        eprintln!("Error: no se especificaron archivos");
        std::process::exit(1);
    }

    for archivo in &archivos {
        println!("Procesando: {}", archivo);
    }
}

Esto ya se parece a un script real. Y sin dependencias externas.

Leer de stdin con std::io::stdin()

En Bash, leer de stdin es tan simple como read linea. En Rust, std::io::stdin() te da un handle para leer la entrada estándar. La forma más común es usar read_to_string para leer todo de golpe, o lines() para leer línea a línea.

Leer stdin línea a línea

use std::io::{self, BufRead};

fn main() {
    let stdin = io::stdin();
    let mut lineas = 0;

    for linea in stdin.lock().lines() {
        match linea {
            Ok(texto) => {
                lineas += 1;
                println!("{}: {}", lineas, texto);
            }
            Err(e) => {
                eprintln!("Error leyendo stdin: {}", e);
                std::process::exit(1);
            }
        }
    }

    eprintln!("Total líneas leídas: {}", lineas);
}

Compila y pruébalo:

cargo build
echo -e "hola\nmundo\nrust" | ./target/debug/crustaceo-args

stdin.lock() devuelve un StdinLock que implementa BufRead. Es importante llamar a lock() porque stdin() comparte el recurso entre hilos, y el lock garantiza acceso exclusivo durante la lectura.

Otra alternativa: si quieres leer todo el stdin de golpe (como cat o cuando esperas poca cantidad de datos):

use std::io::Read;

fn main() {
    let mut stdin = io::stdin();
    let mut buffer = String::new();

    match stdin.read_to_string(&mut buffer) {
        Ok(_) => println!("Leídos {} bytes:\n{}", buffer.len(), buffer),
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    }
}

Esto carga todo en memoria. Para entradas pequeñas va bien; para streams grandes (logs de varios GB), usa lines() o read_line() para no saturar la RAM.

Escribir a stdout y stderr con println! y eprintln!

Ya has usado println! en los ejemplos anteriores. Es la macro para escribir a stdout, y añade un salto de línea al final automáticamente.

Su contraparte para stderr es eprintln!. En scripts de sysadmin, separar stdout de stderr es crucial: stdout lleva la salida «de datos» (lo que otro programa podría pipear), y stderr lleva mensajes para el humano (diagnóstico, errores, progreso).

fn main() {
    // stdout: salida de datos, pipeable
    println!("línea 1 del resultado");
    println!("línea 2 del resultado");

    // stderr: diagnóstico, errores, progreso
    eprintln!("Procesando archivo...");
    eprintln!("Advertencia: formato desconocido en línea 42");
}

Pruébalo para ver la diferencia:

cargo run 2>/dev/null    # solo stdout
cargo run >/dev/null     # solo stderr
cargo run 2>&1 | cat     # ambos mezclados

Si no usas eprintln! para los mensajes de diagnóstico, esos mensajes se cuelan en el pipe y rompen la salida del siguiente programa en la cadena. Es uno de los errores más comunes al empezar con Rust.

print! y eprint! sin salto de línea

A veces no quieres el salto de línea automático. Usa print! (stdout) y eprint! (stderr):

use std::io::{self, Write};

fn main() {
    // Con print! necesitas flush manual
    print!("Procesando... ");
    io::stdout().flush().unwrap();

    // Simula trabajo
    std::thread::sleep(std::time::Duration::from_secs(1));

    println!("hecho!");
}

Fíjate en flush(). print! no incluye salto de línea, así que stdout puede quedarse en el buffer del sistema sin mostrarse hasta que haya un \n o hasta que el buffer se llene. flush() fuerza la escritura inmediata.

Códigos de salida con std::process::exit

En Bash haces exit 0 para éxito y exit 1 (o cualquier otro número) para error. En Rust, por defecto main() devuelve () (tupla vacía), y el proceso termina con código 0 si no hay panic.

Para salir con un código específico, usa std::process::exit(codigo):

use std::process;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    if args.len() < 2 {
        eprintln!("Error: falta el argumento <nombre>");
        process::exit(1);
    }

    let nombre = &args[1];
    println!("Hola, {}!", nombre);
    process::exit(0);  // explícito, aunque no haría falta
}

process::exit() termina el proceso inmediatamente, sin ejecutar los destructores de las variables del stack. En scripts pequeños no importa, pero tenlo en cuenta si tienes recursos que requieran limpieza (archivos temporales, conexiones de red). Para esos casos, mejor usar std::process::Termination o devolver un Result desde main() (lo veremos en el proyecto final).

Los códigos de salida relevantes en Linux/sysadmin:

CódigoSignificado estándar
0Éxito
1Error genérico
2Uso incorrecto de builtin (shell)
126Comando encontrado pero no ejecutable
127Comando no encontrado
128+signalTerminado por señal (128+2 = SIGINT, etc.)
130Script terminado por Ctrl+C (128+SIGINT)

En tus scripts, usa 0 para éxito, 1 para error genérico. Si necesitas más granularidad, define constantes al principio:

const EXIT_SUCCESS: i32 = 0;
const EXIT_FAILURE: i32 = 1;
const EXIT_USAGE: i32 = 2;
const EXIT_NOT_FOUND: i32 = 127;

fn main() {
    // ...
    std::process::exit(EXIT_USAGE);
}

Manejo básico de errores con Result y match

Este es el punto donde Rust se separa de Bash de forma radical. En Bash, un comando falla y te enteras por $?. En Rust, las operaciones que pueden fallar devuelven un Result<T, E>, y el compilador te obliga a manejarlo. No hay olvidos.

Result es un enum con dos variantes:

  • Ok(valor) — la operación funcionó
  • Err(error) — la operación falló

La forma más verbosa de manejarlo es con match:

use std::fs::File;
use std::io::Read;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 2 {
        eprintln!("Uso: {} <archivo>", args[0]);
        std::process::exit(1);
    }

    let mut archivo = match File::open(&args[1]) {
        Ok(f) => f,
        Err(e) => {
            eprintln!("Error al abrir '{}': {}", args[1], e);
            std::process::exit(1);
        }
    };

    let mut contenido = String::new();
    match archivo.read_to_string(&mut contenido) {
        Ok(_) => println!("{}", contenido),
        Err(e) => {
            eprintln!("Error al leer '{}': {}", args[1], e);
            std::process::exit(1);
        }
    }
}

Este patrón se repite: abrir archivo → si falla, mostrar error y salir. Para scripts, es perfectamente válido y legible.

Atajos: unwrap() y expect()

Para scripts rápidos o cuando sabes que una operación no va a fallar (prototipos, ejemplos), puedes usar unwrap():

let contenido = std::fs::read_to_string("archivo.txt").unwrap();

Si unwrap() encuentra un Err, el programa paniquea (entra en pánico) y muestra el error. No es elegantísimo, pero para scripts pequeños donde el fallo es terminal, es aceptable.

expect() es como unwrap() pero te permite personalizar el mensaje de error:

let contenido = std::fs::read_to_string("archivo.txt")
    .expect("No se pudo leer archivo.txt");

En scripts profesionales, usa match o los operadores que veremos después. En prototipos y ejemplos de tutorial, expect() está bien.

El operador ? (propagación de errores)

El operador ? es el atajo más elegante de Rust para errores. Si la expresión es Ok(v), desempaqueta v. Si es Err(e), retorna el error de la función actual.

Pero hay una condición: la función debe devolver un Result. ¿Cómo hacemos que main() devuelva un Result? Así:

use std::fs::File;
use std::io::{self, Read};

fn main() -> io::Result<()> {
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 2 {
        eprintln!("Uso: {} <archivo>", args[0]);
        std::process::exit(1);
    }

    let mut archivo = File::open(&args[1])?;
    let mut contenido = String::new();
    archivo.read_to_string(&mut contenido)?;

    println!("{}", contenido);
    Ok(())
}

io::Result<()> es un alias de Result<(), io::Error>. Significa: «devuelvo Ok con nada dentro, o devuelvo un error de E/S».

Con ?, si File::open falla, el error se propaga automáticamente a quien llamó a main() (el runtime de Rust), que lo mostrará con el mensaje de error correspondiente.

Para scripts de sysadmin que hacen E/S de archivos, esta es la firma ideal de main().

Importante: si usas ?, el código de salida del proceso sigue siendo 0 si main() devuelve Ok(()), y distinto de 0 si devuelve Err(...). Rust runtime se encarga de poner el código de salida correcto automáticamente.

Proyecto: crustaceo-stats — nuestro propio wc con esteroides

Vamos a construir una herramienta real: crustaceo-stats. Lee archivos y muestra estadísticas (líneas, palabras, caracteres) con salida formateada y coloreada. Como wc pero con estilo.

Crea el proyecto:

cargo new crustaceo-stats
cd crustaceo-stats

Primero, el Cargo.toml. Necesitamos la crate colored para los colores en terminal:

[package]
name = "crustaceo-stats"
version = "0.1.0"
edition = "2024"

[dependencies]

colored = «2»

La crate colored es la más sencilla para colorear texto en terminal. No necesita configuración: importas los traits y llamas a .red(), .green(), .bold(), etc., sobre cualquier string.

Ahora el código completo de src/main.rs:

use colored::*;
use std::env;
use std::fs::File;
use std::io::{self, Read};
use std::process;

/// Estadísticas de un archivo: líneas, palabras, caracteres y bytes
#[derive(Debug, Default)]
struct Stats {
    lineas: usize,
    palabras: usize,
    caracteres: usize,
    bytes: usize,
}

impl Stats {
    /// Suma las estadísticas de otro Stats al actual (para totales)
    fn sumar(&mut self, otro: &Stats) {
        self.lineas += otro.lineas;
        self.palabras += otro.palabras;
        self.caracteres += otro.caracteres;
        self.bytes += otro.bytes;
    }
}

/// Calcula estadísticas a partir del contenido de un archivo
fn contar(contenido: &str) -> Stats {
    let lineas = contenido.lines().count();
    let palabras = contenido.split_whitespace().count();
    let caracteres = contenido.chars().count();
    let bytes = contenido.len();

    Stats {
        lineas,
        palabras,
        caracteres,
        bytes,
    }
}

/// Lee un archivo completo y devuelve su contenido como String
fn leer_archivo(ruta: &str) -> io::Result<String> {
    let mut archivo = File::open(ruta)?;
    let mut contenido = String::new();
    archivo.read_to_string(&mut contenido)?;
    Ok(contenido)
}

/// Lee todo el stdin y devuelve el contenido como String
fn leer_stdin() -> io::Result<String> {
    let mut buffer = String::new();
    io::stdin().read_to_string(&mut buffer)?;
    Ok(buffer)
}

/// Formatea una línea de estadísticas con colores
fn formatear_stats(nombre: &str, stats: &Stats, es_total: bool) -> String {
    let linea_stats = format!(
        "{:>8} {:>8} {:>8} {:>8}",
        stats.lineas, stats.palabras, stats.caracteres, stats.bytes
    );

    if es_total {
        format!(
            "{} {:>8}",
            linea_stats.bold().cyan(),
            nombre.bold().cyan()
        )
    } else if nombre.is_empty() {
        // stdin
        format!("{}", linea_stats.green())
    } else {
        format!(
            "{} {:>8}",
            linea_stats.green(),
            nombre.yellow()
        )
    }
}

fn mostrar_ayuda() {
    println!("crustaceo-stats 0.1.0");
    println!("Calcula estadísticas de archivos: líneas, palabras, caracteres y bytes");
    println!();
    println!("{}", "USO:".bold().underline());
    println!("  crustaceo-stats [OPCIONES] [ARCHIVO...]");
    println!("  crustaceo-stats [OPCIONES] (lee de stdin)");
    println!();
    println!("{}", "OPCIONES:".bold().underline());
    println!("  -h, --help     Muestra esta ayuda y sale");
    println!("  -t, --total    Muestra solo el total (no por archivo)");
    println!();
    println!("{}", "EJEMPLOS:".bold().underline());
    println!("  crustaceo-stats archivo.txt");
    println!("  crustaceo-stats *.txt");
    println!("  cat archivo.txt | crustaceo-stats");
}

fn main() -> io::Result<()> {
    let args: Vec<String> = env::args().collect();
    let mut solo_total = false;
    let mut archivos: Vec<String> = Vec::new();

    // Procesar argumentos
    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "-h" | "--help" => {
                mostrar_ayuda();
                process::exit(0);
            }
            "-t" | "--total" => solo_total = true,
            arg if arg.starts_with('-') => {
                eprintln!("Error: opción desconocida '{}'", arg);
                eprintln!("Uso: {} [-h] [-t] [ARCHIVO...]", args[0]);
                process::exit(1);
            }
            arg => archivos.push(arg.to_string()),
        }
        i += 1;
    }

    if archivos.is_empty() {
        // Leer de stdin
        let contenido = leer_stdin()?;
        let stats = contar(&contenido);
        println!("{}", formatear_stats("", &stats, false));
    } else {
        // Leer archivos
        let mut total = Stats::default();
        let mut stats_por_archivo: Vec<(String, Stats)> = Vec::new();

        for ruta in &archivos {
            match leer_archivo(ruta) {
                Ok(contenido) => {
                    let stats = contar(&contenido);
                    total.sumar(&stats);
                    if !solo_total {
                        stats_por_archivo.push((ruta.clone(), stats));
                    }
                }
                Err(e) => {
                    eprintln!("{}: {}: {}", "Error".red().bold(), ruta.yellow(), e);
                    // No salimos, seguimos con el siguiente archivo
                }
            }
        }

        // Mostrar resultados por archivo
        if !solo_total {
            for (ruta, stats) in &stats_por_archivo {
                println!("{}", formatear_stats(ruta, stats, false));
            }
            // Si hay más de un archivo, mostrar total
            if archivos.len() > 1 {
                println!("{}", formatear_stats("total", &total, true));
            }
        } else {
            // Modo solo total: mostrar directamente
            println!("{}", formatear_stats("total", &total, true));
        }
    }

    Ok(())
}

Vamos a desglosar qué hace cada parte:

La estructura Stats

Usamos un struct para agrupar las cuatro métricas que nos interesan: líneas, palabras, caracteres y bytes. Implementamos Default para tener un valor inicial vacío con ..default() o Stats::default().

El método sumar() nos permite acumular estadísticas de varios archivos en un total general. Esto es lo que hace wc cuando le pasas varios archivos: muestra cada uno y luego el total.

La función contar()

contenido.lines().count() — cuenta líneas usando el iterador lines() de &str, que divide por saltos de línea (\n).

contenido.split_whitespace().count() — cuenta palabras separadas por cualquier espacio en blanco. Esto no es exactamente igual que wc (que usa un algoritmo más complejo), pero para el 99% de los casos da el mismo resultado.

contenido.chars().count() — cuenta caracteres Unicode. Importante: chars() cuenta puntos de código Unicode, no bytes. Una letra acentuada (como «é») cuenta como un carácter, aunque ocupe 2 bytes en UTF-8. wc -m hace exactamente esto.

contenido.len() — cuenta bytes. Esto es lo que hace wc -c.

Lectura de archivos con ?

leer_archivo() y leer_stdin() usan el operador ? y devuelven io::Result<String>. Si algo falla (archivo no existe, permisos, etc.), el error se propaga. Pero ojo: en main() no propagamos directamente desde leer_archivo() dentro del bucle — capturamos el error con match y mostramos un mensaje personalizado, permitiendo que el programa siga procesando el resto de archivos.

Salida coloreada

La función formatear_stats() usa la crate colored para añadir color:

  • linea_stats.green() — las cifras en verde
  • nombre.yellow() — el nombre del archivo en amarillo
  • El total en negrita y cian (bold().cyan())

El formato {:>8} alinea a la derecha con 8 caracteres de ancho. Así todas las columnas quedan alineadas.

Manejo de errores robusto

Cuando un archivo no se puede leer, mostramos el error en rojo ("Error".red().bold()) y seguimos con el siguiente archivo. No paramos la ejecución. Esto es importante: si procesas 10 archivos y uno falla, quieres ver los resultados de los otros 9.

El código de salida será 0 aunque algún archivo haya fallado (porque main() devuelve Ok(()) siempre). Si quieres ser estricto, puedes llevar la cuenta de errores y devolver Err(...) al final.

Compilación y prueba

cargo build

Si todo compila (y debería), prueba:

# Probar con un archivo
echo -e "hola mundo\nesto es rust\nvamos alla" > prueba.txt
./target/debug/crustaceo-stats prueba.txt
#       3        6       33       34  prueba.txt

# Probar con varios archivos
cp prueba.txt prueba2.txt
echo "linea extra" >> prueba2.txt
./target/debug/crustaceo-stats prueba.txt prueba2.txt
#       3        6       33       34  prueba.txt
#       4        7       38       39  prueba2.txt
#       7       13       71       73    total

# Probar stdin
cat prueba.txt | ./target/debug/crustaceo-stats
#       3        6       33       34

# Probar archivo inexistente
./target/debug/crustaceo-stats no-existe.txt
# Error: no-existe.txt: No such file or directory (os error 2)

# Probar ayuda
./target/debug/crustaceo-stats --help

¿Ves? Ya tienes una herramienta útil, con colores, que maneja errores, que lee de archivos y de stdin, y que muestra totales. Todo lo que hace wc pero con esteroides.

Mejoras que puedes hacer por tu cuenta

El proyecto es funcional, pero puedes extenderlo:

  • Añadir flag -l, -w, -c, -m para mostrar solo ciertas columnas (como wc)
  • Añadir flag --sort para ordenar por alguna métrica
  • Añadir detección de archivos binarios (leer los primeros bytes para detectar si es texto plano)
  • Mostrar barras de progreso para archivos grandes con la crate indicatif
  • Añadir soporte para globs (*.log) sin que el shell los expanda

En próximos capítulos iremos añadiendo funcionalidades.

Verificación

# 1. El proyecto compila sin warnings
cd ~/crustaceo-stats
cargo build 2>&1 | grep -i "warning" || echo "✅ Sin warnings"

# 2. Funciona con un archivo real
echo -e "linea uno\ nlinea dos\ nlinea tres" > /tmp/test.txt
./target/debug/crustaceo-stats /tmp/test.txt
# Debe mostrar: 3 líneas, ~6 palabras, ~27 caracteres

# 3. Funciona con stdin
cat /tmp/test.txt | ./target/debug/crustaceo-stats

# 4. Maneja archivos inexistentes sin crashear
./target/debug/crustaceo-stats /no/existe 2>&1
# Debe mostrar error y salir con código 0

# 5. Múltiples archivos y total
./target/debug/crustaceo-stats /tmp/test.txt /tmp/test.txt

# 6. Solo total
./target/debug/crustaceo-stats -t /tmp/test.txt /tmp/test.txt

# 7. Release build (binario optimizado)
cargo build --release
ls -lh target/release/crustaceo-stats
# ~400-500KB, un solo binario, cero dependencias externas

Resumen

En este capítulo has aprendido:

ConceptoCódigo clave
Leer argumentosstd::env::args().collect()
Leer stdin línea a líneastdin.lock().lines()
Leer stdin completostdin.read_to_string()
Escribir a stdoutprintln!()
Escribir a stderreprintln!()
Salir con códigostd::process::exit(n)
Manejar erroresmatch + Result
Propagar erroresoperador ?
Colorear salidacrate colored

Y has construido crustaceo-stats, una herramienta real que lee archivos, calcula estadísticas y las muestra con colores. Ya puedes usarla en tu día a día como sustituto mejorado de wc.

En el capítulo 02 veremos cómo gestionar archivos y directorios: leer carpetas, filtrar por extensión, procesar árboles de directorios recursivamente. Todo lo necesario para escribir scripts de sysadmin que manipulen el sistema de archivos como un pez en el agua.


Más información,

Deja una respuesta