6

Menús y diálogos en Rust para linuxeros

Vistas: 1
Menús y diálogos en Rust para linuxeros

Hasta ahora has construido herramientas que reciben argumentos por línea de comandos, procesan archivos, ejecutan comandos del sistema y muestran datos formateados en tablas. Todo funciona, pero la interacción es unidireccional: tú lanzas el programa, este hace su trabajo y termina. Pero hay otro modelo de interacción: el diálogo interactivo. En lugar de pasar todos los parámetros de golpe al invocar el programa, el propio programa te va preguntando qué quieres hacer, te muestra opciones, te pide confirmación antes de acciones destructivas, y te deja navegar por distintos paneles de información. Piensa en htop, nmtui, raspi-config o systemctl status en modo interactivo. Son herramientas que transforman la terminal en una interfaz con menús, botones y diálogos.

Diálogos

Los crates

En Rust puedes construir esto sin recurrir a NCurses ni a librerías C complejas. Dos crates te dan todo lo que necesitas: dialoguer para los diálogos interactivos (menús, selects, confirmaciones, inputs) y console para el control de terminal (limpiar pantalla, colores, ocultar cursor).

En este capítulo vas a construir crustaceo-admin, una herramienta interactiva de administración del sistema con:

  • Menú principal con opciones navegables con flechas
  • Cinco submenús: System Info, Logs, Disk, Network, Processes
  • Confirmación antes de acciones destructivas (matar procesos)
  • Input de parámetros (número de procesos, PID)
  • Manejo de Ctrl+C para salir limpiamente
  • Limpieza de pantalla entre pantallas
  • Formato atractivo con colores y emojis

Si vienes de Bash, piensa en un script con select y read, pero con esteroides:

# Esto en Bash sería un infierno de case y select
PS3="Elige opción: "
select opt in "Info" "Logs" "Salir"; do
  case $opt in
    "Info") uname -a ;;
    "Logs") journalctl -n 10 ;;
    "Salir") break ;;
  esac
done

crustaceo-admin va a ser eso, pero con colores, submenús anidados, Ctrl+C manejado correctamente y una experiencia de usuario que no da vergüenza mostrar.

Vamos allá.

Las crates del capítulo

Antes de escribir código, conoce a las dos crates protagonistas de este capítulo:

  • dialoguer — la crate para input interactivo en terminal. Proporciona Select (menús de opciones), Confirm (sí/no), Input (texto libre), Password (input oculto), MultiSelect (selección múltiple), Sort (ordenar elementos), FuzzySelect (búsqueda difusa), y más. Es mantenida por el equipo de console-rs y funciona sobre console.
  • console — control de terminal de bajo nivel. Proporciona Term para limpiar pantalla, ocultar/mostrar cursor, leer teclas, obtener tamaño de terminal, y style para aplicar colores y estilos ANSI de forma segura.
  • colored — ya la conoces de capítulos anteriores. Añade métodos de color a los strings. En este capítulo la usamos en los submódulos para mantener la coherencia con el código anterior.
  • ctrlc — manejador de señales para Ctrl+C. Te permite ejecutar código de limpieza cuando el usuario pulsa Ctrl+C: restaurar el cursor, mostrar un mensaje de despedida y salir ordenadamente.
  • anyhow — manejo de errores simplificado, ya lo has usado antes.

El proyecto: crustaceo-admin

Crea el proyecto:

cd ~
cargo new crustaceo-admin
cd crustaceo-admin

Edita Cargo.toml:

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

[dependencies]
dialoguer = "0.11"
console = "0.15"
colored = "2"
anyhow = "1"
ctrlc = "3"

Vamos a ver qué aporta cada una:

  • dialoguer 0.11 — menús interactivos, confirmaciones, inputs. Es la estrella del capítulo. Con Select creas menús navegables con flechas del teclado. Con Confirm obtienes un sí/no. Con Input capturas texto libre con validación.
  • console 0.15Term::clear_screen() para limpiar pantalla, Term::hide_cursor() / Term::show_cursor() para ocultar/mostrar el cursor, console::style() para aplicar formato sin los métodos directos de colored. También proporciona Term::read_key() para esperar una pulsación.
  • ctrlc 3 — manejador de Ctrl+C. Cuando el usuario pulsa Ctrl+C, el manejador restaura el cursor, imprime un mensaje y termina el proceso. Sin esta crate, Ctrl+C mataría el proceso dejando la terminal en un estado inconsistente (cursor oculto, sin prompt visible).

Estructura del proyecto

El proyecto tiene múltiples módulos, uno por submenú:

crustaceo-admin/
├── Cargo.toml
└── src/
    ├── main.rs          # Punto de entrada, menú principal, submenús, Ctrl+C
    ├── system.rs        # Información del sistema (hostname, kernel, memoria)
    ├── logs.rs          # Logs del sistema (journalctl, dmesg)
    ├── disk.rs          # Uso de disco (df -h, df -i)
    ├── network.rs       # Red (interfaces, conexiones, estadísticas)
    └── processes.rs     # Gestión de procesos (ps, kill)

main.rs declara los módulos con mod system;, mod logs;, etc., y contiene la lógica de navegación (menú principal y submenús). Cada archivo de módulo contiene las funciones que ejecutan los comandos del sistema y muestran los resultados.

Paso 1: La estructura del menú principal

Vamos a construir el esqueleto de src/main.rs. Este archivo contiene:

  1. Las declaraciones de módulo
  2. El manejador de Ctrl+C
  3. La función main() con el bucle principal
  4. Las funciones de submenú
  5. Utilidades como mostrar_banner() y esperar_tecla()
mod system;
mod logs;
mod disk;
mod network;
mod processes;

use anyhow::Result;
use console::style;
use console::Term;
use dialoguer::{Confirm, Input, Select};

fn main() -> Result<()> {
    let term = Term::stdout();
    term.hide_cursor()?;

    ctrlc::set_handler(move || {
        let t = Term::stdout();
        let _ = t.show_cursor();
        println!("\n{}", style("👋 Hasta luego, crustáceo!").bold().cyan());
        std::process::exit(0);
    })
    .expect("Error al configurar manejador de Ctrl+C");

    loop {
        term.clear_screen()?;
        mostrar_banner()?;

        let opciones = vec![
            "🖥️  Información del Sistema",
            "📋 Logs del Sistema",
            "💾 Disco",
            "🌐 Red",
            "⚙️  Procesos",
            "🚪 Salir",
        ];

        let seleccion = Select::new()
            .with_prompt("Menú principal")
            .items(&opciones)
            .default(0)
            .interact_on_opt(&term)?;

        match seleccion {
            Some(0) => menu_system(&term)?,
            Some(1) => menu_logs(&term)?,
            Some(2) => menu_disk(&term)?,
            Some(3) => menu_network(&term)?,
            Some(4) => menu_processes(&term)?,
            Some(5) | None => {
                term.show_cursor()?;
                println!("\n{}", style("👋 Hasta luego, crustáceo!").bold().cyan());
                break;
            }
            _ => unreachable!(),
        }
    }

    term.show_cursor()?;
    Ok(())
}

Analicemos las piezas clave:

Term::stdout() — obtiene un manejador de la terminal estándar. Es el objeto que usas para limpiar pantalla, ocultar cursor, leer teclas, etc.

term.hide_cursor() — oculta el cursor para que no parpadee mientras navegas por los menús. Hay que restaurarlo al salir con term.show_cursor().

ctrlc::set_handler() — registra un cierre (closure) que se ejecuta cuando el usuario pulsa Ctrl+C. Dentro del closure restauramos el cursor con show_cursor(), mostramos un mensaje y salimos con std::process::exit(0). Es crítico restaurar el cursor porque si no, la terminal se queda sin cursor visible después de salir.

Select::new() — crea un menú de selección. with_prompt define el texto que se muestra encima de las opciones. items recibe un slice con las opciones. default(0) marca la primera opción como preseleccionada. interact_on_opt muestra el menú y devuelve Some(índice) si el usuario seleccionó algo, o None si pulsó Escape/Ctrl+C (dialoguer captura Ctrl+C en interact_on_opt pero no en interact_on).

El método interact_on_opt devuelve Option<usize> en lugar de usize. Esto permite detectar cuando el usuario cancela sin seleccionar (Escape o Ctrl+C) y actuar en consecuencia — en nuestro caso, volver al menú anterior o salir.

Paso 2: El banner y la utilidad de pausa

La función mostrar_banner() pinta el logo ASCII y el título. esperar_tecla() pausa la ejecución hasta que el usuario pulsa una tecla, permitiendo leer la salida de los comandos antes de limpiar la pantalla:

fn mostrar_banner() -> Result<()> {
    println!(
        "{}",
        style(
            r#"
   __________               __
  / ___/ ___/__  ___  ___ _/ /_______  ____
 / (_ / /__/ _ \/ _ \/ _ `/ __/ __/ _ \/ __/
 \___/\___/\___/_//_/\_,_/\__/_/  \___/_/
"#
        )
        .cyan()
        .bold()
    );
    println!(
        "{}",
        style("  🦀  Administrador de sistema interactivo  🦀")
            .bold()
            .white()
    );
    println!(
        "{}",
        style("  ============================================")
            .dim()
            .cyan()
    );
    println!();
    Ok(())
}

fn esperar_tecla(term: &Term) -> Result<()> {
    println!();
    println!("{}", style("──────────────────────────────────────────").dim());
    println!("{}", style("Presiona cualquier tecla para continuar...").dim());
    let _ = term.read_key();
    Ok(())
}

console::style() aplica estilos ANSI. Es similar a colored pero con una sintaxis más funcional: style("texto").cyan().bold() en lugar de "texto".cyan().bold(). En los submódulos usamos ambas según convenga.

term.read_key() bloquea hasta que el usuario pulsa una tecla. Ignoramos el valor devuelto porque solo nos interesa la pausa.

Paso 3: Submenús con dialoguer::Select

Cada submenú es una función con su propio bucle interno que presenta opciones y ejecuta acciones. Veamos el submenú de Sistema:

fn menu_system(term: &Term) -> Result<()> {
    loop {
        term.clear_screen()?;
        println!("{}", style("══════════════════════════════════════════").cyan());
        println!("{}", style("          🖥️  INFORMACIÓN DEL SISTEMA").bold().cyan());
        println!("{}", style("══════════════════════════════════════════").cyan());

        let opciones = vec![
            "📊 Resumen del sistema",
            "🧠 Memoria detallada (free -h)",
            "🔙 Volver al menú principal",
        ];

        let seleccion = Select::new()
            .with_prompt("Submenú Sistema")
            .items(&opciones)
            .default(0)
            .interact_on_opt(&term)?;

        match seleccion {
            Some(0) => {
                system::show_system_info()?;
                esperar_tecla(term)?;
            }
            Some(1) => {
                system::show_memory_detail()?;
                esperar_tecla(term)?;
            }
            Some(2) | None => break,
            _ => unreachable!(),
        }
    }
    Ok(())
}

La estructura es un bucle loop que:

  1. Limpia la pantalla
  2. Pinta una cabecera con el nombre del submenú
  3. Muestra las opciones con Select
  4. Según la selección, ejecuta la acción o rompe el bucle para volver al menú anterior

Cada submenú tiene un color distinto: cian para Sistema, amarillo para Logs, verde para Disco, magenta para Red, azul para Procesos. Esto ayuda a la navegación visual.

El resto de submenús siguen el mismo patrón. Veamos el de Procesos, que es el más complejo porque incluye Confirm e Input:

fn menu_processes(term: &Term) -> Result<()> {
    loop {
        term.clear_screen()?;
        println!("{}", style("══════════════════════════════════════════").blue());
        println!("{}", style("          ⚙️  PROCESOS").bold().blue());
        println!("{}", style("══════════════════════════════════════════").blue());

        let opciones = vec![
            "🔥 Top procesos por CPU",
            "💧 Top procesos por memoria",
            "🔢 Buscar proceso por PID",
            "💀 Matar proceso (SIGTERM)",
            "🔙 Volver al menú principal",
        ];

        let seleccion = Select::new()
            .with_prompt("Submenú Procesos")
            .items(&opciones)
            .default(0)
            .interact_on_opt(&term)?;

        match seleccion {
            Some(0) => {
                let num: usize = Input::new()
                    .with_prompt("¿Cuántos procesos mostrar?")
                    .default(10)
                    .interact_on(&term)?;
                processes::show_top_processes("cpu", num)?;
                esperar_tecla(term)?;
            }
            Some(1) => {
                let num: usize = Input::new()
                    .with_prompt("¿Cuántos procesos mostrar?")
                    .default(10)
                    .interact_on(&term)?;
                processes::show_top_processes("mem", num)?;
                esperar_tecla(term)?;
            }
            Some(2) => {
                let pid_str: String = Input::new()
                    .with_prompt("PID del proceso")
                    .interact_on(&term)?;
                if let Ok(pid) = pid_str.trim().parse::<u32>() {
                    println!("  Mostrando info del PID {}", pid);
                    processes::show_top_processes("pid", 1)?;
                } else {
                    println!("  {} PID inválido", style("✖").red());
                }
                esperar_tecla(term)?;
            }
            Some(3) => {
                let pid_str: String = Input::new()
                    .with_prompt("PID a matar")
                    .interact_on(&term)?;
                let pid: u32 = match pid_str.trim().parse() {
                    Ok(p) => p,
                    Err(_) => {
                        println!("  {} PID inválido", style("✖").red());
                        esperar_tecla(term)?;
                        continue;
                    }
                };

                if Confirm::new()
                    .with_prompt(format!("¿Estás seguro de matar el proceso {}?", pid))
                    .default(false)
                    .interact_on(&term)?
                {
                    match processes::kill_process(pid) {
                        Ok(_) => println!("{} Proceso {} terminado correctamente",
                            style("✓").green(), pid),
                        Err(e) => println!("{} {}", style("✖").red(), e),
                    }
                } else {
                    println!("{} Operación cancelada", style("○").yellow());
                }
                esperar_tecla(term)?;
            }
            Some(4) | None => break,
            _ => unreachable!(),
        }
    }
    Ok(())
}

Aquí ves tres patrones nuevos:

dialoguer::Input

Input::new() crea un campo de texto donde el usuario puede escribir. Con with_prompt defines el mensaje. Con default(10) estableces un valor por defecto. interact_on(&term) muestra el input y devuelve el valor.

El tipo de retorno está determinado por el tipo que anotas: let num: usize = Input::new()... devuelve usize, let pid_str: String = Input::new()... devuelve String. dialoguer hace la conversión automática si el tipo implementa FromStr.

Para el PID, pedimos un String y lo parseamos manualmente para dar un mensaje de error más amigable si el usuario introduce algo que no es un número.

dialoguer::Confirm

Confirm::new() muestra una pregunta con respuesta sí/no. with_prompt es el texto de la pregunta. default(false) establece el valor por defecto (No). El usuario navega con flechas y confirma con Enter.

En el código, antes de matar un proceso preguntamos «¿Estás seguro?» con default(false). Si el usuario confirma, ejecutamos kill_process(). Si no, mostramos «Operación cancelada».

Manejo de errores en input

Cuando el usuario introduce un PID inválido, no queremos que el programa se detenga con un error. Capturamos el error de parseo, mostramos un mensaje y usamos continue para volver al inicio del bucle del submenú.

Paso 4: Módulo system.rs — Información del sistema

El módulo system.rs ejecuta comandos del sistema para obtener información del hardware y el sistema operativo:

use anyhow::Result;
use colored::*;
use std::process::Command;

pub fn show_system_info() -> Result<()> {
    println!("\n{}", console::style("══════════════════════════════════════════").cyan());
    println!("{}", console::style("          INFORMACIÓN DEL SISTEMA").bold().cyan());
    println!("{}", console::style("══════════════════════════════════════════").cyan());

    let hostname = get_cmd_output("hostname", &[]).unwrap_or_else(|_| "desconocido".into());
    println!("  {} {}", "● Hostname:".bold(), hostname);

    let kernel = get_cmd_output("uname", &["-r"]).unwrap_or_else(|_| "desconocido".into());
    println!("  {} {}", "● Kernel:".bold(), kernel);

    let os = get_cmd_output("uname", &["-o"]).unwrap_or_else(|_| "desconocido".into());
    println!("  {} {}", "● SO:".bold(), os);

    let arch = get_cmd_output("uname", &["-m"]).unwrap_or_else(|_| "desconocido".into());
    println!("  {} {}", "● Arquitectura:".bold(), arch);

    let uptime = get_cmd_output("uptime", &["-p"]).unwrap_or_else(|_| "desconocido".into());
    println!("  {} {}", "● Uptime:".bold(), uptime.trim());

    let cpu = get_cmd_output("sh", &[
        "-c", "grep -m1 'model name' /proc/cpuinfo | cut -d: -f2 | sed 's/^ //'"
    ]).unwrap_or_else(|_| "desconocido".into());
    println!("  {} {}", "● CPU:".bold(), cpu.trim());

    let cpus = get_cmd_output("sh", &["-c", "nproc"]).unwrap_or_else(|_| "1".into());
    println!("  {} {} núcleos", "● CPUs:".bold(), cpus.trim());

    let mem_total = get_cmd_output("sh", &[
        "-c", "free -h | grep -i mem | awk '{print $2}'"
    ]).unwrap_or_else(|_| "?".into());
    let mem_used = get_cmd_output("sh", &[
        "-c", "free -h | grep -i mem | awk '{print $3}'"
    ]).unwrap_or_else(|_| "?".into());
    let mem_pct = get_cmd_output("sh", &[
        "-c", "free | grep -i mem | awk '{printf \"%.0f\", $3/$2 * 100}'"
    ]).unwrap_or_else(|_| "0".into());

    let pct: u8 = mem_pct.trim().parse().unwrap_or(0);
    let mem_icon = if pct > 80 { "🟥" } else if pct > 60 { "🟨" } else { "🟩" };
    println!("  {} {} {} usado / {} total", "● Memoria:".bold(),
        mem_icon, mem_used.trim(), mem_total.trim());

    println!("{}", console::style("──────────────────────────────────────────").dim());
    Ok(())
}

fn get_cmd_output(cmd: &str, args: &[&str]) -> Result<String> {
    let output = Command::new(cmd).args(args).output()?;
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

El patrón es el mismo que viste en capítulos anteriores: std::process::Command para ejecutar comandos, parsear la salida y mostrarla con formato.

La función get_cmd_output es una utilidad local que ejecuta un comando y devuelve su stdout como String. Si el comando falla (no existe, no hay permisos), propaga el error con ?. Al llamarla usamos unwrap_or_else con un valor por defecto para que la herramienta siga funcionando aunque algún comando no esté disponible.

Paso 5: Módulo logs.rs — Logs del sistema

use anyhow::Result;
use colored::*;
use std::process::Command;

pub fn show_logs() -> Result<()> {
    println!("\n{}", console::style("══════════════════════════════════════════").yellow());
    println!("{}", console::style("          LOGS DEL SISTEMA (journalctl)").bold().yellow());
    println!("{}", console::style("══════════════════════════════════════════").yellow());

    let output = Command::new("journalctl")
        .args(["--no-pager", "-n", "30", "--output", "short"])
        .output();

    match output {
        Ok(out) => {
            let log = String::from_utf8_lossy(&out.stdout);
            if log.trim().is_empty() {
                println!("  {} No hay logs disponibles (sin systemd o sin permisos)",
                    "⚠".yellow());
            } else {
                for line in log.lines() {
                    println!("  {}", line);
                }
            }
        }
        Err(e) => {
            println!("  {} Error ejecutando journalctl: {}", "✖".red(), e);
            println!("  Prueba con: {}", "sudo journalctl -n 20".yellow());
        }
    }

    println!("\n{}", console::style("──────────────────────────────────────────").dim());
    Ok(())
}

Aquí ves el uso de colored directamente sobre strings: "⚠".yellow(). Esta sintaxis es más concisa que console::style("⚠").yellow() y funciona igual. Usamos una mezcla de ambos estilos en el proyecto para que veas las dos opciones.

Observa que journalctl puede fallar si no tienes permisos o si el sistema no usa systemd. Por eso envolvemos la ejecución en un match en lugar de usar ?: capturamos el error y mostramos un mensaje amigable con sugerencias.

Paso 6: Módulo disk.rs — Disco

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

pub fn show_disk_usage() -> Result<()> {
    println!("\n{}", console::style("══════════════════════════════════════════").green());
    println!("{}", console::style("          USO DE DISCO").bold().green());
    println!("{}", console::style("══════════════════════════════════════════").green());

    let output = Command::new("df")
        .args(["-h", "--exclude-type", "tmpfs",
               "--exclude-type", "devtmpfs", "--exclude-type", "squashfs"])
        .output()
        .context("No se pudo ejecutar df")?;

    let out = String::from_utf8_lossy(&output.stdout);
    for (i, line) in out.lines().enumerate() {
        if i == 0 {
            println!("  {}", console::style(line).bold().white());
        } else {
            let uso_str = line.split_whitespace()
                .nth(4).unwrap_or("0%")
                .trim_end_matches('%');
            let uso: u8 = uso_str.parse().unwrap_or(0);
            let colored_line = if uso >= 90 {
                console::style(line).red()
            } else if uso >= 75 {
                console::style(line).yellow()
            } else {
                console::style(line).green()
            };
            println!("  {}", colored_line);
        }
    }

    println!("\n{}", console::style("──────────────────────────────────────────").dim());
    Ok(())
}

El uso de disco se colorea según umbrales: rojo si ≥90%, amarillo si ≥75%, verde en otro caso. Es el mismo patrón que usaste en crustaceo-df del capítulo 05.

Paso 7: Módulo network.rs — Red

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

pub fn show_network_interfaces() -> Result<()> {
    println!("\n{}", console::style("══════════════════════════════════════════").magenta());
    println!("{}", console::style("          INTERFACES DE RED").bold().magenta());
    println!("{}", console::style("══════════════════════════════════════════").magenta());

    let output = Command::new("ip")
        .args(["-br", "addr"])
        .output()
        .context("No se pudo ejecutar ip addr")?;

    let out = String::from_utf8_lossy(&output.stdout);
    for line in out.lines() {
        if line.contains("UP") || line.contains("UNKNOWN") {
            println!("  {}", console::style(line).cyan());
        } else if line.contains("DOWN") {
            println!("  {}", console::style(line).red());
        } else {
            println!("  {}", line);
        }
    }

    println!("\n{}", console::style("──────────────────────────────────────────").dim());
    Ok(())
}

Las interfaces activas (UP) se muestran en cian, las caídas (DOWN) en rojo. Simple y efectivo.

Paso 8: Módulo processes.rs — Procesos

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

pub fn show_top_processes(orden: &str, num: usize) -> Result<()> {
    println!("\n{}", console::style("══════════════════════════════════════════").blue());
    println!("{}", console::style(
        format!("          PROCESOS (orden: {}, top {})", orden, num)
    ).bold().blue());
    println!("{}", console::style("══════════════════════════════════════════").blue());

    let output = Command::new("ps")
        .args(["aux", "--no-headers"])
        .output()
        .context("No se pudo ejecutar ps aux")?;

    let out = String::from_utf8_lossy(&output.stdout);
    let mut lines: Vec<&str> = out.lines().collect();

    match orden {
        "mem" => lines.sort_by(|a, b| {
            let a_mem = a.split_whitespace().nth(3).unwrap_or("0");
            let b_mem = b.split_whitespace().nth(3).unwrap_or("0");
            b_mem.parse::<f32>().unwrap_or(0.0)
                .partial_cmp(&a_mem.parse::<f32>().unwrap_or(0.0))
                .unwrap_or(std::cmp::Ordering::Equal)
        }),
        "cpu" => lines.sort_by(|a, b| {
            let a_cpu = a.split_whitespace().nth(2).unwrap_or("0");
            let b_cpu = b.split_whitespace().nth(2).unwrap_or("0");
            b_cpu.parse::<f32>().unwrap_or(0.0)
                .partial_cmp(&a_cpu.parse::<f32>().unwrap_or(0.0))
                .unwrap_or(std::cmp::Ordering::Equal)
        }),
        _ => {}
    }

    println!("  {}", console::style(
        "USER         PID   %CPU  %MEM  RSS      COMMAND"
    ).bold().white());
    println!("  {}", console::style(
        "─────────────────────────────────────────────────"
    ).dim());

    for line in lines.iter().take(num) {
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() >= 11 {
            let cpu: f32 = parts[2].parse().unwrap_or(0.0);
            let mem: f32 = parts[3].parse().unwrap_or(0.0);
            let cmd = parts[10..].join(" ");

            let styled = if cpu > 20.0 || mem > 20.0 {
                console::style(format!("  {:<10} {:<6} {:<5} {:<5} {:<9} {}",
                    parts[0], parts[1], parts[2], parts[3], parts[4], cmd)).red()
            } else if cpu > 5.0 || mem > 5.0 {
                console::style(format!("  {:<10} {:<6} {:<5} {:<5} {:<9} {}",
                    parts[0], parts[1], parts[2], parts[3], parts[4], cmd)).yellow()
            } else {
                console::style(format!("  {:<10} {:<6} {:<5} {:<5} {:<9} {}",
                    parts[0], parts[1], parts[2], parts[3], parts[4], cmd)).dim()
            };
            println!("{}", styled);
        }
    }

    println!("\n{}", console::style("──────────────────────────────────────────").dim());
    Ok(())
}

pub fn kill_process(pid: u32) -> Result<()> {
    let output = Command::new("kill")
        .args([pid.to_string().as_str()])
        .output()
        .context("No se pudo ejecutar kill")?;

    if output.status.success() {
        println!("  {} Proceso {} terminado con SIGTERM", "✓".green(), pid);
    } else {
        let err = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("Error al matar proceso {}: {}", pid, err.trim());
    }
    Ok(())
}

La función show_top_processes ejecuta ps aux y ordena según el criterio (cpu, mem). Usa colored para colorear cada línea según el consumo: rojo si >20%, amarillo si >5%, tenue en otro caso.

La función kill_process envía SIGTERM (la señal por defecto de kill). Si falla (permisos, proceso inexistente), usa anyhow::bail!() para devolver un error con contexto.

Compilación y prueba

Compila el proyecto:

cd ~/crustaceo-admin
cargo build --release

La compilación debe completarse sin errores. Si tienes problemas, revisa que todas las dependencias estén correctamente declaradas en Cargo.toml y que los nombres de los módulos coincidan con los nombres de los archivos.

Ejecuta la herramienta:

./target/release/crustaceo-admin

Navega por los menús con las flechas del teclado, selecciona con Enter, vuelve atrás con Escape, y sal con Ctrl+C o seleccionando «Salir».

Sobre el manejo de Ctrl+C

El manejo de Ctrl+C merece una explicación más detallada. Cuando ejecutas un programa en Rust y pulsas Ctrl+C, el sistema operativo envía la señal SIGINT al proceso. Sin un manejador personalizado, el comportamiento por defecto es terminar el proceso inmediatamente.

El problema: si hemos ocultado el cursor con term.hide_cursor(), al morir el proceso el cursor sigue oculto. La terminal se vuelve invisible para el usuario. No es catastrófico — se puede restaurar con reset o stty sane — pero es una mala experiencia.

Con ctrlc::set_handler, registramos un cierre que se ejecuta cuando llega SIGINT:

ctrlc::set_handler(move || {
    let t = Term::stdout();
    let _ = t.show_cursor();
    println!("\n{}", style("👋 Hasta luego, crustáceo!").bold().cyan());
    std::process::exit(0);
}).expect("Error al configurar manejador de Ctrl+C");

El cierre:

  1. Restaura el cursor con show_cursor()
  2. Imprime un mensaje de despedida
  3. Termina el proceso con exit(0)

Hay que tener en cuenta que el cierre se ejecuta en un contexto donde no se pueden usar todas las funciones de Rust de forma segura. Por eso escribimos:

let _ = t.show_cursor();

El let _ = ignora cualquier error (no podemos propagar errores con ? dentro de un cierre de manejador de señal). Si show_cursor falla, el programa continúa con el exit.

Verificación

Script de verificación completo para asegurarte de que todo funciona:

#!/bin/bash
# test-crustaceo-admin.sh

set -e

echo "🔍 Prueba 1: Compilación"
cd ~/crustaceo-admin
cargo build --release 2>&1 | tail -1

echo ""
echo "🔍 Prueba 2: Ayuda del binario (no esperamos --help, solo que exista)"
if [ -f ./target/release/crustaceo-admin ]; then
    echo "Binario existe: OK"
    ls -lh ./target/release/crustaceo-admin
fi

echo ""
echo "🔍 Prueba 3: Ejecución no interactiva (modo batch con timeout)"
echo "No se puede probar interactivamente, pero verificamos que el binario arranca"
timeout 2 ./target/release/crustaceo-admin 2>&1 || true

echo ""
echo "🔍 Prueba 4: Verificar que los submódulos existen"
grep -q "mod system" src/main.rs && echo "Módulo system: OK" || echo "Módulo system: FALLO"
grep -q "mod logs" src/main.rs && echo "Módulo logs: OK" || echo "Módulo logs: FALLO"
grep -q "mod disk" src/main.rs && echo "Módulo disk: OK" || echo "Módulo disk: FALLO"
grep -q "mod network" src/main.rs && echo "Módulo network: OK" || echo "Módulo network: FALLO"
grep -q "mod processes" src/main.rs && echo "Módulo processes: OK" || echo "Módulo processes: FALLO"

echo ""
echo "🔍 Prueba 5: Verificar dependencias"
grep -q "dialoguer" Cargo.toml && echo "dialoguer: OK" || echo "dialoguer: FALLO"
grep -q "console" Cargo.toml && echo "console: OK" || echo "console: FALLO"
grep -q "ctrlc" Cargo.toml && echo "ctrlc: OK" || echo "ctrlc: FALLO"
grep -q "anyhow" Cargo.toml && echo "anyhow: OK" || echo "anyhow: FALLO"
grep -q "colored" Cargo.toml && echo "colored: OK" || echo "colored: FALLO"

echo ""
echo "🔍 Prueba 6: Tests unitarios"
cargo test 2>&1 | tail -5

echo ""
echo "🔍 Prueba 7: El binario es ELF"
file ./target/release/crustaceo-admin | grep -q "ELF"
echo "Es un binario ELF: OK"

echo ""
echo ""
echo "✅ Todas las pruebas completadas"
echo "Para probar la interacción: ./target/release/crustaceo-admin"

Ejecuta:

chmod +x test-crustaceo-admin.sh
./test-crustaceo-admin.sh

Para probar la interacción manualmente, ejecuta el binario en una terminal:

./target/release/crustaceo-admin

Deberías ver:

  1. El banner de crustáceo en cian
  2. Un menú con seis opciones navegables con flechas
  3. Cada submenú muestra su cabecera con su color correspondiente
  4. Las acciones ejecutan comandos del sistema y muestran los resultados
  5. Ctrl+C restaura el cursor y termina limpiamente

Ampliaciones posibles

El proyecto crustaceo-admin es una base sobre la que puedes construir. Algunas ideas:

MultiSelect para logs: en lugar de mostrar todas las líneas, deja que el usuario seleccione qué niveles de log quiere ver (emerg, alert, crit, err, warn, notice, info, debug).

Password para sudo: si algún comando requiere sudo, usa dialoguer::Password para pedir la contraseña de forma segura (sin eco en pantalla).

FuzzySelect para procesos: si tienes cientos de procesos, FuzzySelect permite buscar escribiendo parte del nombre.

Barras de progreso: añade indicatif para mostrar spinners mientras se ejecutan comandos lentos como journalctl --since yesterday.

Resumen del capítulo

En este capítulo has aprendido a:

  • Construir menús interactivos con dialoguer::Select, con opciones navegables y selección por Enter.
  • Pedir confirmación con dialoguer::Confirm antes de acciones destructivas como matar procesos.
  • Capturar input de usuario con dialoguer::Input, con valores por defecto y validación básica.
  • Manejar Ctrl+C con la crate ctrlc, restaurando el cursor y saliendo limpiamente.
  • Controlar la terminal con console::Term: limpiar pantalla, ocultar/mostrar cursor, leer teclas.
  • Aplicar formato y colores con console::style() y colored para menús más atractivos.
  • Estructurar un proyecto multi-módulo con main.rs y submódulos independientes para cada funcionalidad.
  • Capturar errores de comandos externos de forma amigable, mostrando sugerencias al usuario.

Y lo más importante: has construido una herramienta interactiva que podría ser la base de un htop propio, un instalador, un asistente de configuración, o cualquier interfaz de administración que necesites.

Referencias

Deja una respuesta