3

Ejecutar comandos en Rust para Linuxeros

Vistas: 2
Ejecutar comandos en Rust para Linuxeros

En el capítulo anterior construiste un analizador de logs con regex, BufReader y conteos con BTreeMap. Procesabas archivos línea a línea, filtrabas por nivel de severidad y mostrabas una tabla con barras de progreso. Mola, pero seguías dentro de los límites de Rust puro. Ahora toca salir al mundo real. Los scripts no viven aislados, llaman a df, free, ps, uptime, grep, systemctl, docker, y un largo etcétera. En Bash es tan sencillo como escribir el comando entre backticks o con $(...). En Rust tienes std::process::Command, que te da un control total sobre cómo ejecutas comandos externos. Pero hay un problema: los comandos fallan. El binario no está instalado, el usuario no tiene permisos, el disco está lleno, el proceso lleva demasiado tiempo… y tu programa tiene que manejar todo eso sin crashear.

Ejecutando comandos

Como te indicaba en la introducción, una cosa es ejecutar comandos en bash, y otra ligeramente distinta es hacerlo desde Rust, pero, como vas a ver a continuación, todo tiene sus ventajas e inconvenientes. Así en este capítulo vamos a ver como hacer determinadas acciones como,

  • Ejecutar comandos del sistema con std::process::Command
  • Capturar stdout, stderr y el código de salida
  • Gestionar errores de verdad con anyhow y thiserror
  • Crear tus propios tipos de error personalizados
  • Encadenar comandos (pipe) como en Bash
  • Construir crustaceo-sysinfo, un monitor del sistema que ejecuta df, free, ps y uptime, y lo presenta todo con colores

std::process::Command: ejecutar comandos desde Rust

std::process::Command es la puerta de entrada a los comandos externos. Construyes un comando, le pasas argumentos, y lo ejecutas. La forma más básica:

use std::process::Command;

fn main() {
    let mut cmd = Command::new("whoami");
    let salida = cmd.output().expect("fallo al ejecutar whoami");

    println!("stdout: {}", String::from_utf8_lossy(&salida.stdout));
    println!("stderr: {}", String::from_utf8_lossy(&salida.stderr));
    println!("código salida: {:?}", salida.status.code());
}

Compila y ejecuta:

cargo new crustaceo-cmd --name crustaceo-cmd
cd crustaceo-cmd
cargo run
# stdout: lorenzo
# stderr:
# código salida: Some(0)

Command::new("whoami") crea el comando. .output() lo ejecuta y devuelve un Result<Output, std::io::Error>. Output contiene tres campos:

  • stdout: Vec<u8> — la salida estándar como bytes
  • stderr: Vec<u8> — el error estándar como bytes
  • status: ExitStatus — el código de salida y si terminó por señal

String::from_utf8_lossy() convierte los bytes a String. Si los bytes no son UTF-8 válidos, los reemplaza con « en lugar de petar.

Pasar argumentos

Los comandos rara vez se ejecutan sin argumentos. Se pasan con .arg() o .args():

use std::process::Command;

fn main() {
    // Con .arg() uno a uno
    let salida = Command::new("ls")
        .arg("-la")
        .arg("/home")
        .output()
        .expect("fallo al ejecutar ls");

    println!("{}", String::from_utf8_lossy(&salida.stdout));
}

O con .args() y un slice:

let salida = Command::new("df")
    .args(["-h", "--total"])
    .output()
    .expect("fallo al ejecutar df");

Regla de oro: cada argumento va en su propio .arg(). NO metas varios argumentos en un solo string como harías en Bash ("df -h --total"). Eso le pasa a df un único argumento que es literalmente "-h --total", y df no sabe qué hacer con eso.

En Bash haces:

df -h --total

En Rust haces:

Command::new("df").args(["-h", "--total"]);

Cada elemento del array es un argumento separado, exactamente como el shell separa por espacios.

Capturar stdout como String

Es tan común que merece la pena tener una función helper:

use std::process::Command;

fn ejecutar(comando: &str, args: &[&str]) -> Result<String, std::io::Error> {
    let salida = Command::new(comando)
        .args(args)
        .output()?;

    if salida.status.success() {
        Ok(String::from_utf8_lossy(&salida.stdout).to_string())
    } else {
        let stderr = String::from_utf8_lossy(&salida.stderr);
        Err(std::io::Error::new(
            std::io::ErrorKind::Other,
            format!("{} falló: {}", comando, stderr.trim()),
        ))
    }
}

fn main() -> std::io::Result<()> {
    let resultado = ejecutar("df", &["-h", "/"])?;
    println!("{}", resultado);
    Ok(())
}

Esta función ya maneja algo fundamental: si el comando devuelve un código de salida distinto de cero, lo trata como error. En Bash harías algo parecido comprobando $?.

Capturar stderr por separado

A veces necesitas stdout y stderr por separado. Por ejemplo, ffprobe (de FFmpeg) escribe la información del video a stderr, no a stdout. Tenerlos separados te da control total:

use std::process::Command;
use std::str;

fn main() {
    let salida = Command::new("sh")
        .args(["-c", "echo 'stdout: hola' && echo 'stderr: errorcito' >&2"])
        .output()
        .expect("fallo al ejecutar");

    println!("=== STDOUT ===");
    print!("{}", str::from_utf8(&salida.stdout).unwrap_or("(no utf-8)"));

    println!("=== STDERR ===");
    print!("{}", str::from_utf8(&salida.stderr).unwrap_or("(no utf-8)"));

    println!("=== CÓDIGO ===");
    println!("{:?}", salida.status.code());
}

Compila y ejecuta:

cargo run
# === STDOUT ===
# stdout: hola
# === STDERR ===
# stderr: errorcito
# === CÓDIGO ===
# Some(0)

Usar str::from_utf8 en lugar de from_utf8_lossy te permite detectar si la salida no es UTF-8 válido. Para comandos del sistema, from_utf8_lossy suele ser suficiente, pero si necesitas rigor, usa from_utf8 y maneja el error.

Ejecutar sin capturar salida: .status()

A veces no te interesa capturar la salida del comando, solo ejecutarlo y saber si funcionó. Por ejemplo, al crear un directorio con mkdir:

use std::process::Command;

fn main() {
    let status = Command::new("mkdir")
        .args(["-p", "/tmp/prueba-rust"])
        .status()
        .expect("fallo al ejecutar mkdir");

    if status.success() {
        println!("Directorio creado correctamente");
    } else {
        eprintln!("Error al crear directorio: código {:?}", status.code());
        std::process::exit(1);
    }
}

.status() ejecuta el comando y devuelve el ExitStatus sin capturar stdout/stderr. Son heredados del proceso padre, así que el comando escribe directamente a tu terminal.

Spawn: ejecutar y olvidar

Para lanzar un proceso en segundo plano sin esperar a que termine (como poner un & al final en Bash):

use std::process::Command;

fn main() {
    let mut child = Command::new("sleep")
        .arg("30")
        .spawn()
        .expect("fallo al lanzar sleep");

    // Hacemos otras cosas mientras sleep corre...
    println!("sleep lanzado en segundo plano, PID: {}", child.id());

    // Esperamos a que termine
    let status = child.wait().expect("fallo al esperar sleep");
    println!("sleep terminó con código: {:?}", status.code());
}

Manejo de errores REAL: qué pasa cuando el comando no existe

Vale, hasta aquí todo correcto si los comandos existen y funcionan. Pero esto es el mundo real. ¿Qué pasa si el usuario no tiene instalado df? ¿O si ejecuta tu herramienta en un contenedor Alpine mínimo? Vamos a verlo.

Comando no encontrado

use std::process::Command;

fn main() {
    match Command::new("comando-inexistente").output() {
        Ok(salida) => {
            println!("stdout: {}", String::from_utf8_lossy(&salida.stdout));
            println!("stderr: {}", String::from_utf8_lossy(&salida.stderr));
            println!("código: {:?}", salida.status.code());
        }
        Err(e) => {
            eprintln!("Error al ejecutar el comando: {}", e);
        }
    }
}

Compila y ejecuta:

cargo run
# Error al ejecutar comando: No such file or directory (os error 2)

Fíjate: el Err no viene del ExitStatus del comando, sino que Command::new("comando-inexistente").output() devuelve directamente un error de E/S porque el binario no se encuentra en el PATH. Esto es diferente a cuando el comando existe pero devuelve un código de salida distinto de cero — en ese caso output() devuelve Ok(Output) con status.code() = Some(1) o similar.

La distinción es importante:

Situaciónoutput() devuelveCómo se detecta
Comando no existe en PATHErr(io::Error) con NotFoundmatch o ?
Comando existe pero fallaOk(Output) con status.code() != 0salida.status.success()
Comando existe y funcionaOk(Output) con status.code() == 0salida.status.success()

Manejo robusto: combinando ambos casos

Una función que maneje ambos escenarios correctamente:

use std::process::Command;
use std::io;

fn ejecutar_comando(comando: &str, args: &[&str]) -> Result<String, String> {
    let salida = match Command::new(comando).args(args).output() {
        Ok(o) => o,
        Err(e) => {
            if e.kind() == io::ErrorKind::NotFound {
                return Err(format!("'{}' no está instalado. Instálalo con tu gestor de paquetes.", comando));
            }
            return Err(format!("Error al ejecutar '{}': {}", comando, e));
        }
    };

    if salida.status.success() {
        Ok(String::from_utf8_lossy(&salida.stdout).to_string())
    } else {
        let stderr = String::from_utf8_lossy(&salida.stderr);
        Err(format!("'{}' falló (código {:?}): {}",
            comando,
            salida.status.code(),
            stderr.trim()))
    }
}

fn main() {
    match ejecutar_comando("df", &["-h", "/"]) {
        Ok(salida) => print!("{}", salida),
        Err(e) => eprintln!("Error: {}", e),
    }

    match ejecutar_comando("comando-inexistente", &[]) {
        Ok(salida) => print!("{}", salida),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Esto ya es un manejo de errores de nivel profesional. Y sin crates externas.

Error handling profesional con anyhow y thiserror

Lo que acabamos de hacer con Result<String, String> funciona, pero tiene limitaciones. El tipo String como error no lleva contexto: no puedes añadir causas, no puedes definir errores específicos, no puedes hacer matching por tipo de error.

Aquí entran anyhow y thiserror, dos crates del mismo ecosistema que se complementan:

  • anyhow: para errores en aplicaciones y scripts. Te permite usar anyhow::Result<T> como tipo de retorno y propagar cualquier error con ?. Ideal para main() y funciones de alto nivel donde no necesitas saber el tipo exacto de error.
  • thiserror: para definir tipos de error personalizados con #[derive(Error)]. Ideal para librerías o cuando necesitas que los errores tengan nombres y campos específicos.

Ya usaste anyhow en el capítulo anterior. Ahora vamos a profundizar y añadir thiserror a la mezcla.

Crea el proyecto para este capítulo:

cargo new crustaceo-sysinfo
cd crustaceo-sysinfo

Edita Cargo.toml:

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

[dependencies]

anyhow = «1» thiserror = «2» colored = «2» chrono = «0.4»

Vamos a ver cada crate:

CratePara qué
anyhowErrores genéricos con anyhow::Result<T>, propagar cualquier error con ?
thiserrorDefinir errores personalizados con derive macro
coloredColores en terminal (la conoces del capítulo 01)
chronoFormatear timestamps con zona horaria

Primeros pasos con thiserror

thiserror te permite definir errores con una macro derive. Mira qué sencillo:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum SysError {
    #[error("Comando '{0}' no encontrado. Instálalo con tu gestor de paquetes.")]
    ComandoNoEncontrado(String),

    #[error("El comando '{0}' falló con código {1}: {2}")]
    ComandoFallido(String, i32, String),

    #[error("Error de E/S: {0}")]
    IoError(#[from] std::io::Error),
}

Cada variante del enum lleva un atributo #[error("...")] con un mensaje que puede usar {0}, {1}, etc., para referirse a los campos. El trait Display se implementa automáticamente.

El #[from] en IoError hace que puedas convertir un std::io::Error a SysError automáticamente con el operador ?. Es una de las características más potentes de thiserror.

Usar errores personalizados con comandos

Juntémoslo todo con un ejemplo completo que ejecuta df y maneja errores reales:

use anyhow::{Context, Result};
use colored::*;
use std::process::Command;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum SysError {
    #[error("'{0}' no está instalado. Ejecuta: sudo apt install {0}")]
    ComandoNoEncontrado(String),

    #[error("'{0}' falló (código {1}): {2}")]
    ComandoFallido(String, i32, String),

    #[error("Error de E/S ejecutando '{0}': {1}")]
    IoError(String, #[source] std::io::Error),
}

/// Ejecuta un comando y devuelve stdout como String, con manejo de errores profesional
fn ejecutar(comando: &str, args: &[&str]) -> Result<String> {
    let salida = Command::new(comando)
        .args(args)
        .output()
        .map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                SysError::ComandoNoEncontrado(comando.to_string())
            } else {
                SysError::IoError(comando.to_string(), e)
            }
        })?;

    if salida.status.success() {
        Ok(String::from_utf8_lossy(&salida.stdout).to_string())
    } else {
        let stderr = String::from_utf8_lossy(&salida.stderr).trim().to_string();
        let codigo = salida.status.code().unwrap_or(-1);
        Err(SysError::ComandoFallido(comando.to_string(), codigo, stderr).into())
    }
}

fn main() -> Result<()> {
    match ejecutar("df", &["-h", "/"]) {
        Ok(salida) => {
            println!("{}", "=== df ===".bold().cyan());
            println!("{}", salida);
        }
        Err(e) => {
            eprintln!("{} {}", "Error:".red().bold(), e);
            // En este caso, al ser el main, podríamos devolver Err(e)
            // pero queremos mostrar el error y continuar
        }
    }

    Ok(())
}

Compila:

cargo build
./target/debug/crustaceo-sysinfo
# === df ===
# Filesystem      Size  Used Avail Use% Mounted on
# /dev/sda1        50G   20G   28G  42% /

Ahora prueba qué pasa si el comando no existe:

// En main(), sustituye temporalmente:
match ejecutar("comando-inexistente", &[]) {
    Ok(salida) => println!("{}", salida),
    Err(e) => eprintln!("{} {}", "Error:".red().bold(), e),
}
cargo run
# Error: 'comando-inexistente' no está instalado. Ejecuta: sudo apt install comando-inexistente

El error es descriptivo, dice exactamente qué falta y cómo solucionarlo. Eso es un buen error.

El poder de #[from] en thiserror

Fíjate en el detalle de IoError:

#[error("Error de E/S ejecutando '{0}': {1}")]
IoError(String, #[source] std::io::Error),

El #[source] indica que el segundo campo es la causa del error. Esto permite a anyhow mostrar la cadena completa de errores cuando haces {:#}:

Err(SysError::IoError("df".into(), e)).context("al ejecutar df")?

Si usas {:?} o {:#} en la presentación, ves la causa raíz. Esto es clave para depuración.

Contexto con anyhow

Una de las funciones más útiles de anyhow es el método .context() (del trait Context). Te permite añadir información adicional a un error cuando lo propagas:

use anyhow::{Context, Result};
use std::fs::File;

fn leer_config() -> Result<String> {
    let ruta = "/etc/crustaceo/config.toml";
    let contenido = std::fs::read_to_string(ruta)
        .with_context(|| format!("No se pudo leer el archivo de configuración '{}'", ruta))?;
    Ok(contenido)
}

fn main() -> Result<()> {
    let cfg = leer_config()?;
    println!("Config: {}", cfg);
    Ok(())
}

Si el archivo no existe, verás:

Error: No se pudo leer el archivo de configuración '/etc/crustaceo/config.toml'

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

Sin .context(), solo verías «No such file or directory», y tendrías que adivinar qué archivo faltaba. Con .context(), sabes exactamente qué operación falló y por qué.

En los comandos del sistema esto es oro puro:

fn obtener_uptime() -> Result<String> {
    let salida = Command::new("uptime")
        .arg("-p")
        .output()
        .context("No se pudo ejecutar 'uptime' — ¿estás en un sistema Linux?")?
        .stdout;

    Ok(String::from_utf8_lossy(&salida).trim().to_string())
}

Pipe entre comandos en Rust

En Bash, el pipe es la operación más básica:

ps aux | grep rust | head -5 | awk '{print $2, $11}'

En Rust no tienes operador | para procesos, pero tienes varias formas de encadenar comandos. La más directa: capturar la salida de uno y pasársela como stdin al siguiente.

Pipe manual: stdout → stdin

use std::process::{Command, Stdio};

fn main() -> anyhow::Result<()> {
    // Primer comando: ps aux
    let ps = Command::new("ps")
        .args(["aux"])
        .stdout(Stdio::piped())
        .spawn()?;

    // Segundo comando: grep rust — recibe stdin del pipe
    let grep = Command::new("grep")
        .arg("rust")
        .stdin(Stdio::from(ps.stdout.unwrap()))  // pipe!
        .stdout(Stdio::piped())
        .spawn()?;

    // Tercer comando: head -5
    let head = Command::new("head")
        .args(["-5"])
        .stdin(Stdio::from(grep.stdout.unwrap()))
        .stdout(Stdio::piped())
        .spawn()?;

    // Cuarto comando: awk
    let awk = Command::new("awk")
        .args(["'{print $2, $11}'"])
        .stdin(Stdio::from(head.stdout.unwrap()))
        .stdout(Stdio::piped())
        .spawn()?;

    let salida = awk.wait_with_output()?;
    println!("{}", String::from_utf8_lossy(&salida.stdout));

    Ok(())
}

El truco está en Stdio::piped() para crear el pipe y Stdio::from(child.stdout.unwrap()) para conectar la salida de un proceso a la entrada del siguiente.

Pero esto es verboso. Vamos a crear una función helper que haga pipes como en Bash:

use std::process::{Command, Output, Stdio};

/// Encadena comandos como un pipe de Bash: pipe(&["ps aux", "grep rust", "head -5"])
fn pipe(comandos: &[&str]) -> anyhow::Result<Output> {
    anyhow::ensure!(!comandos.is_empty(), "No hay comandos para ejecutar");

    // Dividir cada comando en programa y argumentos
    let mut partes: Vec<Vec<&str>> = comandos
        .iter()
        .map(|c| c.split_whitespace().collect())
        .collect();

    let programa = partes.remove(0);
    let (cmd, args) = (programa[0], &programa[1..]);

    // Primer comando
    let mut child = Command::new(cmd)
        .args(args)
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|e| anyhow::anyhow!("Error al ejecutar '{}': {}", cmd, e))?;

    let mut prev_stdout = child.stdout.take();

    // Comandos intermedios
    for comando in &partes {
        let (cmd, args) = (comando[0], &comando[1..]);
        child = Command::new(cmd)
            .args(args)
            .stdin(Stdio::from(prev_stdout.unwrap()))
            .stdout(Stdio::piped())
            .spawn()
            .map_err(|e| anyhow::anyhow!("Error al ejecutar '{}': {}", cmd, e))?;
        prev_stdout = child.stdout.take();
    }

    // Último comando: capturar salida
    let salida = child.wait_with_output()?;

    if !salida.status.success() {
        let stderr = String::from_utf8_lossy(&salida.stderr);
        anyhow::bail!("El pipe falló (código {:?}): {}",
            salida.status.code(), stderr.trim());
    }

    Ok(salida)
}

fn main() -> anyhow::Result<()> {
    let salida = pipe(&[
        "ps aux",
        "grep rust",
        "head -5",
    ])?;

    println!("{}", String::from_utf8_lossy(&salida.stdout));
    Ok(())
}

Esta función pipe() recibe un array de strings, donde cada string es un comando con sus argumentos. Internamente los divide por espacios y los encadena como pipes de Bash.

Capturar la salida completa del pipe

A veces quieres capturar todo el resultado del pipe sin imprimirlo directamente:

let resultado = pipe(&[
    "df -h",
    "grep /dev/sd",
    "awk '{print $5, $6}'",
])?;

for linea in String::from_utf8_lossy(&resultado.stdout).lines() {
    if let Some((pct, punto)) = linea.split_once(' ') {
        println!("Disco {}: {} usado", punto, pct);
    }
}

Proyecto: crustaceo-sysinfo

Vale, basta de fragmentos. Vamos a construir la herramienta completa del capítulo: crustaceo-sysinfo, un monitor de sistema que ejecuta comandos reales de Linux y presenta la información formateada con colores.

¿Qué hace?

  1. Uso de disco (df -h) — particiones montadas, uso en porcentaje
  2. Memoria RAM (free -h) — total, usada, libre, swap
  3. Procesos principales (ps aux --sort=-%mem | head -6) — top 5 por consumo de memoria
  4. Tiempo de actividad (uptime -p) — desde cuándo está funcionando el sistema

Todo con una salida limpia, coloreada, y con manejo de errores profesional.

El Cargo.toml

Ya lo tienes de antes, pero confirma:

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

[dependencies]

anyhow = «1» thiserror = «2» colored = «2» chrono = «0.4»

El código completo de src/main.rs

use anyhow::{Context, Result};
use chrono::Local;
use colored::*;
use std::process::{Command, Stdio};
use thiserror::Error;

// ═══════════════════════════════════════════════
// Error personalizado con thiserror
// ═══════════════════════════════════════════════

#[derive(Error, Debug)]
pub enum SysError {
    #[error("'{0}' no está instalado.\n  → Instálalo con: sudo apt install {0}")]
    ComandoNoEncontrado(String),

    #[error("'{0}' falló (código {1}):\n  → {2}")]
    ComandoFallido(String, i32, String),

    #[error("Error al ejecutar '{0}': {1}")]
    Ejecucion(String, #[source] std::io::Error),
}

// ═══════════════════════════════════════════════
// Ejecutor de comandos con errores reales
// ═══════════════════════════════════════════════

/// Ejecuta un comando, captura stdout y gestiona errores de forma profesional
fn ejecutar(comando: &str, args: &[&str]) -> Result<String> {
    let salida = Command::new(comando)
        .args(args)
        .output()
        .map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                SysError::ComandoNoEncontrado(comando.to_string())
            } else {
                SysError::Ejecucion(comando.to_string(), e)
            }
        })
        .context(format!("Ejecutando '{}' con argumentos {:?}", comando, args))?;

    if salida.status.success() {
        Ok(String::from_utf8_lossy(&salida.stdout).to_string())
    } else {
        let stderr = String::from_utf8_lossy(&salida.stderr).trim().to_string();
        let codigo = salida.status.code().unwrap_or(-1);
        Err(SysError::ComandoFallido(comando.to_string(), codigo, stderr))
            .context(format!("Procesando salida de '{}'", comando))
    }
}

/// Ejecuta un pipe de comandos (como `cmd1 | cmd2 | cmd3`)
fn pipe(comandos: &[&str]) -> Result<Vec<String>> {
    anyhow::ensure!(!comandos.is_empty(), "No se especificaron comandos para el pipe");

    let mut partes: Vec<Vec<&str>> = comandos
        .iter()
        .map(|c| c.split_whitespace().collect())
        .collect();

    let programa = partes.remove(0);
    let (cmd, args) = (programa[0], &programa[1..]);

    let mut child = Command::new(cmd)
        .args(args)
        .stdout(Stdio::piped())
        .spawn()
        .with_context(|| format!("Iniciando pipe: '{}'", comandos[0]))?;

    let mut prev_stdout = child.stdout.take();

    for comando in &partes {
        let (cmd, args) = (comando[0], &comando[1..]);
        child = Command::new(cmd)
            .args(args)
            .stdin(Stdio::from(prev_stdout.unwrap()))
            .stdout(Stdio::piped())
            .spawn()
            .with_context(|| format!("Pipe intermedio: '{}'", comando.join(" ")))?;
        prev_stdout = child.stdout.take();
    }

    let salida = child.wait_with_output()?;
    let stdout = String::from_utf8_lossy(&salida.stdout);

    Ok(stdout.lines().map(|l| l.to_string()).collect())
}

// ═══════════════════════════════════════════════
// Funciones de obtención de datos del sistema
// ═══════════════════════════════════════════════

fn obtener_uptime() -> Result<String> {
    let salida = ejecutar("uptime", &["-p"])
                .context("No se pudo obtener el uptime")?;
    Ok(salida.trim().trim_start_matches("up ").to_string())
}

fn obtener_memoria() -> Result<Vec<String>> {
    let lineas = ejecutar("free", &["-h"])
                .context("No se pudo obtener la información de memoria")?;
    Ok(lineas.lines().map(|l| l.to_string()).collect())
}

fn obtener_disco() -> Result<Vec<String>> {
    // Mostramos solo sistemas de archivos reales (ext4, btrfs, xfs, zfs)
    let lineas = pipe(&[
        "df -h",
        "grep -E '^(/dev/|overlay)'",
    ]).context("No se pudo obtener la información de disco")?;

    if lineas.is_empty() {
        // Fallback: mostrar todos los sistemas de archivos si grep no encuentra nada
        ejecutar("df", &["-h"])
            .context("No se pudo obtener df")
            .map(|s| s.lines().map(|l| l.to_string()).collect())
    } else {
        Ok(lineas)
    }
}

fn obtener_procesos() -> Result<Vec<String>> {
    let lineas = pipe(&[
        "ps aux",
        "sort -k4 -r -n",     // ordenar por %mem descendente
        "head -6",             // cabecera + top 5
    ]).context("No se pudo obtener la lista de procesos")?;
    Ok(lineas)
}

// ═══════════════════════════════════════════════
// Formateo de la salida
// ═══════════════════════════════════════════════

fn mostrar_encabezado(titulo: &str, color: &dyn colored::Colorize) {
    println!();
    println!("{}", "┌──────────────────────────────────────────────┐".cyan());
    println!("{} {:<42} {}", "│".cyan(), titulo.bold(), "│".cyan());
    println!("{}", "└──────────────────────────────────────────────┘".cyan());
    println!();
}

fn mostrar_uptime(uptime: &str) {
    mostrar_encabezado("⏱  UPTIME", &colored::Cyan);
    println!("    El sistema lleva funcionando: {}\n", uptime.green().bold());
}

fn mostrar_memoria(lineas: &[String]) {
    mostrar_encabezado("💾  MEMORIA RAM", &colored::Cyan);

    for (i, linea) in lineas.iter().enumerate() {
        if i == 0 {
            // Cabecera
            println!("{}", linea.cyan().bold());
        } else if linea.contains("Swap") {
            println!("{}", linea.yellow());
        } else {
            println!("{}", linea.green());
        }
    }
    println!();
}

fn mostrar_disco(lineas: &[String]) {
    mostrar_encabezado("💿  USO DE DISCO", &colored::Cyan);

    for (i, linea) in lineas.iter().enumerate() {
        if i == 0 {
            println!("{}", linea.cyan().bold());
            continue;
        }

        // Detectar si el uso supera el 80% para colorear en rojo
        let campos: Vec<&str> = linea.split_whitespace().collect();
        if let Some(pct_str) = campos.get(4) {
            let pct = pct_str.trim_end_matches('%');
            if let Ok(n) = pct.parse::<u8>() {
                if n >= 90 {
                    println!("{}", linea.red());
                } else if n >= 75 {
                    println!("{}", linea.yellow());
                } else {
                    println!("{}", linea.green());
                }
            } else {
                println!("{}", linea);
            }
        } else {
            println!("{}", linea);
        }
    }
    println!();
}

fn mostrar_procesos(lineas: &[String]) {
    mostrar_encabezado("⚙️  TOP 5 PROCESOS (por memoria)", &colored::Cyan);

    for (i, linea) in lineas.iter().enumerate() {
        if i == 0 {
            println!("{}", linea.cyan().bold());
        } else {
            // Extraer usuario, %mem, %cpu y comando
            let partes: Vec<&str> = linea.split_whitespace().collect();
            if partes.len() >= 11 {
                let usuario = partes[0];
                let cpu = partes[2];
                let mem = partes[3];
                let comando = partes[10..].join(" ");

                println!(
                    "  {:>2}. {:<10} CPU: {:>5}  MEM: {:>5}  {}",
                    i,
                    usuario.cyan(),
                    cpu.yellow(),
                    mem.red().bold(),
                    comando.dimmed(),
                );
            } else {
                println!("  {}", linea);
            }
        }
    }
    println!();
}

fn mostrar_error(e: &anyhow::Error, contexto: &str) {
    eprintln!("{} {}: {}", "❌".red().bold(), contexto.red().bold(), e);
}

// ═══════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════

fn main() -> Result<()> {
    // Preámbulo
    println!();
    println!("{}", "🦀  crustaceo-sysinfo v0.1.0".cyan().bold());
    println!("{}", "═".repeat(50).cyan());
    let ahora = Local::now().format("%Y-%m-%d %H:%M:%S");
    println!("  Informe generado: {}", ahora.to_string().cyan());
    println!("{}", "═".repeat(50).cyan());

    // 1. Uptime
    match obtener_uptime() {
        Ok(u) => mostrar_uptime(&u),
        Err(e) => mostrar_error(&e, "uptime"),
    }

    // 2. Memoria
    match obtener_memoria() {
        Ok(m) => mostrar_memoria(&m),
        Err(e) => mostrar_error(&e, "memoria"),
    }

    // 3. Disco
    match obtener_disco() {
        Ok(d) => mostrar_disco(&d),
        Err(e) => mostrar_error(&e, "disco"),
    }

    // 4. Procesos
    match obtener_procesos() {
        Ok(p) => mostrar_procesos(&p),
        Err(e) => mostrar_error(&e, "procesos"),
    }

    // Pie
    println!("{}", "═".repeat(50).cyan());
    println!("{}", "  Información del sistema recopilada con éxito ✅".green().bold());
    println!();

    Ok(())
}

Desglose del código

Errores personalizados (SysError) :

#[derive(Error, Debug)]
pub enum SysError {
    #[error("'{0}' no está instalado.\n  → Instálalo con: sudo apt install {0}")]
    ComandoNoEncontrado(String),
    // ...
}

Cada variante lleva su mensaje. Cuando el error se muestre con {}, aparece el mensaje formateado con los valores de los campos. El #[source] en Ejecucion permite que anyhow encadene la causa raíz.

Función ejecutar() :

Maneja tres escenarios:

  1. El comando no existe en el PATH → Err(...) con mensaje de cómo instalarlo
  2. El comando existe pero fallaErr(...) con código de salida y stderr
  3. El comando funcionaOk(stdout) con la salida capturada

El .context() de anyhow añade información adicional sobre dónde falló exactamente.

Función pipe() :

Implementa pipes entre comandos usando Stdio::piped() y Stdio::from(child.stdout.take()). Cada comando recibe como stdin la stdout del anterior. El último comando captura toda la salida.

Funciones obtener_*() :

Cada una ejecuta comandos diferentes. obtener_disco() usa pipe para filtrar solo dispositivos reales (/dev/*). Si el grep no encuentra nada (sistema sin esos dispositivos), cae en un fallback que muestra todo df -h.

Formateo :

Cada función mostrar_*() recibe las líneas de texto y las colorea:

  • Uptime en verde brillante
  • Cabeceras en cian negrita
  • Uso de disco >90% en rojo, >75% en amarillo, resto en verde
  • Procesos con colores por campo: usuario en cian, CPU en amarillo, memoria en rojo
  • Errores en rojo con el icono ❌

Verificación

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

# 2. Ejecutar la herramienta completa
./target/debug/crustaceo-sysinfo
# Debe mostrar: uptime, memoria, disco y procesos con colores

# 3. Probar que los comandos individuales funcionan
./target/debug/crustaceo-sysinfo 2>&1 | head -20
# Debe mostrar el encabezado y al menos uptime

# 4. Probar con un comando que no existe (simular)
# Edita temporalmente el código y cambia "uptime" por "uptime-no-existe"
# luego compila y ejecuta:
# Debe mostrar: ❌ uptime: 'uptime-no-existe' no está instalado.
#               → Instálalo con: sudo apt install uptime-no-existe

# 5. Probar pipe
./target/debug/crustaceo-sysinfo 2>&1 | grep -c "PROCESOS"
# Debe devolver 1 (la sección de procesos aparece una vez)

# 6. Release build
cargo build --release
ls -lh target/release/crustaceo-sysinfo
# ~800KB-1MB, un solo binario estático

# 7. Copiar a ~/bin y ejecutar desde cualquier lugar
cp target/release/crustaceo-sysinfo ~/bin/
crustaceo-sysinfo
# Debe funcionar igual

Salida esperada

Cuando ejecutes la herramienta, verás algo como:

🦀  crustaceo-sysinfo v0.1.0
══════════════════════════════════════════════
  Informe generado: 2026-06-24 15:30:42
══════════════════════════════════════════════

┌──────────────────────────────────────────────┐
│ ⏱  UPTIME                                    │
└──────────────────────────────────────────────┘

    El sistema lleva funcionando: 3 days, 12 hours, 42 minutes

┌──────────────────────────────────────────────┐
│ 💾  MEMORIA RAM                               │
└──────────────────────────────────────────────┘

               total        used        free      shared  buff/cache   available
Mem:           7.6Gi       3.2Gi       1.1Gi       245Mi       3.3Gi       3.9Gi
Swap:          2.0Gi       256Mi       1.8Gi


┌──────────────────────────────────────────────┐
│ 💿  USO DE DISCO                              │
└──────────────────────────────────────────────┘

Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        50G   20G   28G  42% /
/dev/sda2       100G   85G   11G  89% /home

┌──────────────────────────────────────────────┐
│ ⚙️  TOP 5 PROCESOS (por memoria)              │
└──────────────────────────────────────────────┘

  USER         PID  %CPU  %MEM COMMAND
   1. lorenzo   1234   2.3  12.5  /usr/bin/firefox ...
   2. root       567   0.1   8.2  /usr/bin/mysqld ...
   3. lorenzo    789   1.5   6.3  /usr/bin/code ...
   4. lorenzo   2345   0.8   4.1  /usr/bin/slack ...
   5. root       345   0.2   3.8  /usr/sbin/sshd ...

══════════════════════════════════════════════
  Información del sistema recopilada con éxito ✅

Los valores variarán según tu sistema, pero la estructura y los colores deben ser los mismos.

Prueba de manejo de errores real

Vamos a hacer una prueba específica de errores. Crea este archivo test-errores.sh:

#!/bin/bash
# test-errores.sh - Prueba el manejo de errores de crustaceo-sysinfo

echo "🔍 Prueba 1: Comando no encontrado"
cd ~/crustaceo-sysinfo
cat > /tmp/test-no-existe.rs << 'EOF'
use std::process::Command;

fn main() {
    match Command::new("comando-inexistente").output() {
        Ok(salida) => {
            println!("stdout: {}", String::from_utf8_lossy(&salida.stdout));
            println!("código: {:?}", salida.status.code());
        }
        Err(e) => {
            // Esto es lo que queremos probar: que se detecta correctamente
            if e.kind() == std::io::ErrorKind::NotFound {
                println!("✅ Error detectado correctamente: comando no encontrado");
            } else {
                println!("❌ Error inesperado: {}", e);
            }
        }
    }
}
EOF

cargo build --release 2>/dev/null
echo ""
echo "🔍 Prueba 2: Ejecutar crustaceo-sysinfo completo"
./target/release/crustaceo-sysinfo

echo ""
echo "🔍 Prueba 3: Verificar que el binario es autónomo"
ldd target/release/crustaceo-sysinfo 2>&1 | grep "not a dynamic executable"
echo "✅ Binario estático, sin dependencias dinámicas"

Ejecuta:

chmod +x test-errores.sh
./test-errores.sh

Resumen

ConceptoRustBash equivalente
Ejecutar comando simpleCommand::new("cmd").output()cmd o $(cmd)
Pasar argumentos.args(["-a", "-b"])cmd -a -b
Capturar stdoutString::from_utf8_lossy(&output.stdout)salida=$(cmd)
Capturar stderrString::from_utf8_lossy(&output.stderr)salida=$(cmd 2>&1)
Pipe entre comandosStdio::from(child.stdout.unwrap())cmd1 | cmd2
Ejecutar y olvidar.spawn()cmd &
Error: comando no existeio::ErrorKind::NotFoundcommand not found
Error: código de salida != 0salida.status.success()$? -ne 0
Error personalizadoderive thiserror::Errorecho "Error: ..."; exit 1
Añadir contexto al error.context("mensaje")No hay equivalente directo

En el próximo capítulo veremos cómo leer y escribir archivos de configuración en formatos estándar (TOML, YAML, JSON), cómo persistir datos de tus herramientas y cómo crear scripts configurables sin hardcodear rutas. Todo lo necesario para que tus herramientas parezcan programas de verdad.


Deja una respuesta