Desarrollo de CLI Robustas en Rust: Creando Herramientas de Línea de Comandos Interactivas y Eficientes 💻
Este tutorial te guiará paso a paso en la creación de aplicaciones de línea de comandos (CLI) con Rust. Exploraremos cómo gestionar argumentos con `clap` y cómo construir interfaces de usuario de terminal interactivas con `tui-rs`, elevando tus herramientas a un nuevo nivel de profesionalismo y usabilidad.
Introducción al Desarrollo de CLI en Rust 🚀
Las aplicaciones de línea de comandos (CLI) son herramientas esenciales en el día a día de desarrolladores y usuarios avanzados. Rust, con su rendimiento, seguridad de memoria y robustez, es un lenguaje ideal para construir CLI potentes y fiables. En este tutorial, no solo aprenderemos a procesar argumentos simples, sino también a crear interfaces de usuario de terminal (TUI) interactivas que transformarán una CLI básica en una experiencia de usuario rica.
¿Por qué Rust para CLI? 🤔
Rust ofrece una combinación única de ventajas que lo hacen destacar para el desarrollo de CLI:
- Rendimiento: Ejecutables rápidos y eficientes, crucial para herramientas que se usan con frecuencia o procesan grandes volúmenes de datos.
- Seguridad: El sistema de ownership y borrowing de Rust elimina muchas clases de errores comunes, como los punteros nulos y las carreras de datos, haciendo que tus herramientas sean más fiables.
- Control: Acceso de bajo nivel al hardware y al sistema operativo, lo que permite crear herramientas muy optimizadas.
- Binarios estáticos: Fácil distribución, ya que los binarios compilados suelen incluir todas las dependencias y no necesitan un runtime instalado.
- Comunidad y ecosistema: Un ecosistema creciente con excelentes crates para la gestión de argumentos (
clap), interfaces de terminal (tui-rs), y más.
Preparando el Entorno 🛠️
Antes de sumergirnos en el código, asegúrate de tener Rust y Cargo instalados en tu sistema. Si no es así, puedes instalarlos siguiendo las instrucciones en la página oficial de Rust: rustup.rs.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Una vez instalado, verifica tu instalación:
rustc --version
cargo --version
Ahora, crearemos un nuevo proyecto de Cargo para nuestra CLI:
cargo new my_cli_tool
cd my_cli_tool
Dependencias Esenciales 📦
Para este tutorial, utilizaremos dos crates principales:
clap: Para el análisis y gestión de argumentos de línea de comandos. Es robusto, flexible y permite definir interfaces CLI complejas de manera declarativa.tui-rs: Para construir interfaces de usuario de terminal (TUI) interactivas y ricas. Ofrece widgets y un sistema de renderizado eficiente.
Editaremos nuestro Cargo.toml para añadir estas dependencias. Asegúrate de usar las versiones más recientes o compatibles. En este ejemplo, usaremos versiones comunes.
[package]
name = "my_cli_tool"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.0", features = ["derive"] }
tui = { version = "0.19", package = "tui-rs", features = ["full-crossterm"] }
crossterm = "0.26"
Parte 1: Gestión de Argumentos con clap ✨
clap (Command Line Argument Parser) es un crate potente y popular para analizar argumentos de línea de comandos en Rust. Permite definir la estructura de tu CLI de forma declarativa y genera automáticamente la ayuda (--help) y maneja errores de análisis.
Estructura Básica con clap 🏗️
Comenzaremos con una CLI sencilla que toma un comando y una opción.
Modifica src/main.rs:
use clap::{Parser, Subcommand};
// 1. Define la estructura principal de la aplicación CLI
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Habilita el modo verboso
#[arg(short, long, default_value_t = false)]
verbose: bool,
/// Define los subcomandos disponibles
#[command(subcommand)]
command: Option<Commands>,
}
// 2. Define los subcomandos como un Enum
#[derive(Subcommand, Debug)]
enum Commands {
/// Muestra un mensaje de saludo
Hello {
/// Nombre de la persona a saludar
#[arg(short, long)]
name: Option<String>,
},
/// Suma dos números enteros
Add {
/// Primer número
num1: i32,
/// Segundo número
num2: i32,
},
}
fn main() {
let cli = Cli::parse();
if cli.verbose {
println!("Modo verboso activado!");
}
match &cli.command {
Some(Commands::Hello { name }) => {
let user_name = name.as_deref().unwrap_or("mundo");
println!("¡Hola, {}!", user_name);
}
Some(Commands::Add { num1, num2 }) => {
println!("{} + {} = {}", num1, num2, num1 + num2);
}
None => {
println!("Ningún comando especificado. Usa --help para ver las opciones.");
}
}
}
Probando nuestra CLI 🧪
Ahora puedes compilar y ejecutar tu herramienta:
cargo run -- --help
Verás la ayuda generada automáticamente por clap:
Usage: my_cli_tool [OPTIONS] [COMMAND]
Commands:
hello Muestra un mensaje de saludo
add Suma dos números enteros
help Print this message or the help of the given subcommand(s)
Options:
-v, --verbose Habilita el modo verboso
-h, --help Print help
-V, --version Print version
Prueba los comandos:
cargo run -- hello
cargo run -- hello --name Rustacean
cargo run -- add 5 10
cargo run -- -v add 20 30
Parte 2: Construyendo una Interfaz de Usuario de Terminal (TUI) con tui-rs 🖥️
Para aplicaciones CLI más complejas, una interfaz de usuario de terminal (TUI) puede ofrecer una experiencia mucho más rica y dinámica que los comandos estáticos. tui-rs es un crate fenomenal para esto, permitiéndote crear interfaces con widgets, paneles, y manejar la entrada del usuario.
Conceptos Básicos de tui-rs 🎨
tui-rs funciona creando una snapshot de lo que debe verse en la terminal en cada tick (actualización) y renderizándola. Esto evita parpadeos y permite animaciones fluidas.
Los componentes clave son:
Terminal: Controla el backend (crossterm en nuestro caso) y se encarga de dibujar.Frame: El área donde se dibujan los widgets. Proporciona métodos para dibujar.Layout: Para organizar widgets en diferentes áreas de la terminal (paneles, filas, columnas).Widget: Elementos visuales como párrafos, bloques de texto, listas, tablas, etc.- Eventos: Entrada del usuario (teclado, ratón) para interacción.
Ejemplo de TUI Sencilla: Un Contador Interactivo 🔢
Vamos a crear una TUI simple que muestre un contador y permita incrementarlo o decrementarlo con las teclas.
Primero, definimos el estado de nuestra aplicación TUI:
struct App {
counter: i32,
}
impl App {
fn new() -> App {
App { counter: 0 }
}
}
Ahora, implementemos la función principal para la TUI. Añade un nuevo subcomando tui a nuestro Commands enum en src/main.rs:
// ... (código existente)
#[derive(Subcommand, Debug)]
enum Commands {
// ... (comandos existentes)
/// Abre una interfaz interactiva en la terminal
Tui,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if cli.verbose {
println!("Modo verboso activado!");
}
match &cli.command {
Some(Commands::Hello { name }) => {
let user_name = name.as_deref().unwrap_or("mundo");
println!("¡Hola, {}!", user_name);
}
Some(Commands::Add { num1, num2 }) => {
println!("{} + {} = {}", num1, num2, num1 + num2);
}
Some(Commands::Tui) => {
// Ejecuta la aplicación TUI
run_tui_app()?;
}
None => {
println!("Ningún comando especificado. Usa --help para ver las opciones.");
}
}
Ok(())
}
// Nuevo módulo para la lógica TUI
mod tui_app {
use std::{io, time::{Duration, Instant}};
use crossterm::{event::{self, Event, KeyCode, KeyModifiers}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, execute};
use tui::{Terminal, Frame};
use tui::backend::CrosstermBackend;
use tui::widgets::{Block, Borders, Paragraph};
use tui::layout::{Layout, Constraint, Direction};
use tui::style::{Style, Color, Modifier};
pub struct App {
pub counter: i32,
pub should_quit: bool,
}
impl App {
pub fn new() -> App {
App { counter: 0, should_quit: false }
}
pub fn on_up(&mut self) {
self.counter += 1;
}
pub fn on_down(&mut self) {
self.counter -= 1;
}
pub fn on_key(&mut self, key_code: KeyCode) {
match key_code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up => self.on_up(),
KeyCode::Down => self.on_down(),
_ => {},
}
}
}
// Función para dibujar la UI
pub fn ui<B: CrosstermBackend>(f: &mut Frame<B>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()
)
.split(f.size());
let block = Block::default()
.title("Contador TUI")
.borders(Borders::ALL);
let paragraph = Paragraph::new(format!("Valor actual: {}", app.counter))
.style(Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD))
.block(Block::default().title("Estado").borders(Borders::ALL));
f.render_widget(block, chunks[0]);
f.render_widget(paragraph, chunks[1]);
let help_text = Paragraph::new("Presiona 'q' para salir, flecha arriba/abajo para cambiar el contador.")
.style(Style::default().fg(Color::Gray));
f.render_widget(help_text, f.size().inner(&tui::layout::Rect { x: 0, y: f.size().height - 1, width: f.size().width, height: 1 }));
}
// Función principal para correr la TUI
pub fn run_app<B: CrosstermBackend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
let tick_rate = Duration::from_millis(250);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui(f, &app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
app.on_key(key.code);
}
}
if last_tick.elapsed() >= tick_rate {
// Aquí puedes añadir lógica de actualización periódica si es necesario
last_tick = Instant::now();
}
if app.should_quit {
return Ok(());
}
}
}
pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
execute!(io::stdout(), EnterAlternateScreen)?;
terminal::enable_raw_mode()?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn restore_terminal(mut terminal: Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, crossterm::cursor::Show)?; // Mostrar el cursor al salir
terminal.show_cursor()?;
Ok(())
}
}
fn run_tui_app() -> Result<(), Box<dyn std::error::Error>> {
use tui_app::*;
let mut terminal = setup_terminal()?;
let app = App::new();
let res = run_app(&mut terminal, app);
restore_terminal(terminal)?;
res
}
Compila y ejecuta la aplicación TUI:
cargo run -- tui
Deberías ver una interfaz de terminal simple con un contador. Usa las flechas arriba/abajo para cambiar el valor y q para salir.
Desglose del Código TUI 📖
AppStruct: Contiene el estado de nuestra aplicación TUI (counteryshould_quit). También tiene métodos para manejar eventos y actualizar el estado.uiFunction: Esta función es responsable de dibujar los widgets en la pantalla. UtilizaLayoutpara dividir la pantalla en áreas yBlockyParagraphcomo widgets básicos. Los widgets se dibujan en unFrame.run_appFunction: El bucle principal de la aplicación TUI. En cada iteración (o tick), dibuja la UI y verifica los eventos del teclado. Sishould_quitestrue, el bucle termina.setup_terminalyrestore_terminal: Funciones para configurar el terminal en modo raw (donde tu aplicación maneja cada pulsación de tecla) y restaurarlo a su estado normal al salir. Esto es crucial para la interactividad.
Parte 3: Combinando clap y tui-rs para una CLI Avanzada 🎯
Ahora que entendemos cómo funcionan clap y tui-rs por separado, podemos integrarlos para crear una CLI que ofrezca modos de operación tanto basados en argumentos como interactivos. Nuestro ejemplo ya hace esto al tener un subcomando Tui que lanza la interfaz.
Diseño de una CLI Robusta 🛡️
Una CLI robusta considera varios aspectos:
- Flexibilidad: Permite tanto operaciones rápidas con argumentos como exploraciones interactivas.
- Manejo de errores: Proporciona mensajes claros y útiles al usuario.
- Ayuda clara: Una
--helpbien estructurada es fundamental. - Salida consistente: Ya sea texto simple o una TUI, la salida debe ser predecible.
- Performance: Especialmente importante para herramientas de uso frecuente.
Ejemplo: Un Administrador de Tareas Sencillo (TUI con Persistencia) 📝
Vamos a extender nuestra aplicación para que tenga un subcomando task que permita añadir y listar tareas, y un subcomando tui-tasks que abra una TUI interactiva para gestionar esas tareas.
Para la persistencia, utilizaremos un crate sencillo como serde para serializar a un archivo JSON.
Primero, actualiza Cargo.toml para incluir serde y serde_json:
[dependencies]
clap = { version = "4.0", features = ["derive"] }
tui = { version = "0.19", package = "tui-rs", features = ["full-crossterm"] }
crossterm = "0.26"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Ahora, modificamos src/main.rs para añadir la lógica de tareas:
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::{fs, io::{self, Write}, path::PathBuf};
// --- CLI Structs y Enums --- (mantener las existentes y añadir el nuevo subcomando)
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[arg(short, long, default_value_t = false)]
verbose: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Muestra un mensaje de saludo
Hello {
#[arg(short, long)]
name: Option<String>,
},
/// Suma dos números enteros
Add {
num1: i32,
num2: i32,
},
/// Abre una interfaz interactiva en la terminal
Tui,
/// Gestiona tus tareas (añadir, listar)
Task { #[command(subcommand)] action: TaskAction },
/// Abre una interfaz interactiva para gestionar tareas
TuiTasks,
}
#[derive(Subcommand, Debug)]
enum TaskAction {
/// Añade una nueva tarea
Add { description: String },
/// Lista todas las tareas
List,
}
// --- Lógica de Tareas ---
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Task {
pub id: usize,
pub description: String,
pub completed: bool,
}
pub struct TaskManager {
tasks: Vec<Task>,
file_path: PathBuf,
}
impl TaskManager {
pub fn new(file_name: &str) -> io::Result<Self> {
let file_path = PathBuf::from(file_name);
let tasks = if file_path.exists() {
let contents = fs::read_to_string(&file_path)?;
serde_json::from_str(&contents).unwrap_or_else(|_| vec![])
} else {
vec![]
};
Ok(TaskManager { tasks, file_path })
}
fn save_tasks(&self) -> io::Result<()> {
let json_string = serde_json::to_string_pretty(&self.tasks)?;
fs::write(&self.file_path, json_string)?;
Ok(())
}
pub fn add_task(&mut self, description: String) -> io::Result<()> {
let new_id = self.tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
self.tasks.push(Task { id: new_id, description, completed: false });
self.save_tasks()
}
pub fn list_tasks(&self) -> &Vec<Task> {
&self.tasks
}
pub fn toggle_task_completion(&mut self, id: usize) -> io::Result<()> {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
task.completed = !task.completed;
self.save_tasks()?;
Ok(())
} else {
Err(io::Error::new(io::ErrorKind::NotFound, format!("Task with ID {} not found", id)))
}
}
pub fn delete_task(&mut self, id: usize) -> io::Result<()> {
let initial_len = self.tasks.len();
self.tasks.retain(|t| t.id != id);
if self.tasks.len() < initial_len {
self.save_tasks()
} else {
Err(io::Error::new(io::ErrorKind::NotFound, format!("Task with ID {} not found", id)))
}
}
}
// --- Función main actualizada ---
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut task_manager = TaskManager::new("tasks.json")?;
if cli.verbose {
println!("Modo verboso activado!");
}
match &cli.command {
Some(Commands::Hello { name }) => {
let user_name = name.as_deref().unwrap_or("mundo");
println!("¡Hola, {}!", user_name);
}
Some(Commands::Add { num1, num2 }) => {
println!("{} + {} = {}", num1, num2, num1 + num2);
}
Some(Commands::Tui) => {
run_tui_app_counter()?; // Reutilizamos la app TUI de contador
}
Some(Commands::Task { action }) => match action {
TaskAction::Add { description } => {
task_manager.add_task(description.clone())?;
println!("Tarea '{}' añadida.", description);
}
TaskAction::List => {
println!("--- Mis Tareas ---");
if task_manager.list_tasks().is_empty() {
println!("No hay tareas.");
} else {
for task in task_manager.list_tasks() {
let status = if task.completed { "[X]" } else { "[ ]" };
println!("{} {}: {}", status, task.id, task.description);
}
}
println!("-------------------");
}
},
Some(Commands::TuiTasks) => {
run_tui_tasks_app(task_manager)?;
}
None => {
println!("Ningún comando especificado. Usa --help para ver las opciones.");
}
}
Ok(())
}
// --- tui_app para el Contador (mantener como está, solo renombramos la fn run_tui_app) ---
mod tui_counter_app {
// ... (El contenido de 'mod tui_app' anterior, solo ajusta los nombres si es necesario)
// Asegúrate de que App, ui, run_app, setup_terminal, restore_terminal sean públicas
use std::{io, time::{Duration, Instant}};
use crossterm::{event::{self, Event, KeyCode, KeyModifiers}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, execute};
use tui::{Terminal, Frame};
use tui::backend::CrosstermBackend;
use tui::widgets::{Block, Borders, Paragraph};
use tui::layout::{Layout, Constraint, Direction};
use tui::style::{Style, Color, Modifier};
pub struct App {
pub counter: i32,
pub should_quit: bool,
}
impl App {
pub fn new() -> App {
App { counter: 0, should_quit: false }
}
pub fn on_up(&mut self) {
self.counter += 1;
}
pub fn on_down(&mut self) {
self.counter -= 1;
}
pub fn on_key(&mut self, key_code: KeyCode) {
match key_code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up => self.on_up(),
KeyCode::Down => self.on_down(),
_ => {},
}
}
}
pub fn ui<B: CrosstermBackend>(f: &mut Frame<B>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()
)
.split(f.size());
let block = Block::default()
.title("Contador TUI")
.borders(Borders::ALL);
let paragraph = Paragraph::new(format!("Valor actual: {}", app.counter))
.style(Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD))
.block(Block::default().title("Estado").borders(Borders::ALL));
f.render_widget(block, chunks[0]);
f.render_widget(paragraph, chunks[1]);
let help_text = Paragraph::new("Presiona 'q' para salir, flecha arriba/abajo para cambiar el contador.")
.style(Style::default().fg(Color::Gray));
f.render_widget(help_text, f.size().inner(&tui::layout::Rect { x: 0, y: f.size().height - 1, width: f.size().width, height: 1 }));
}
pub fn run_app<B: CrosstermBackend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
let tick_rate = Duration::from_millis(250);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui(f, &app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
app.on_key(key.code);
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
if app.should_quit {
return Ok(());
}
}
}
pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
execute!(io::stdout(), EnterAlternateScreen)?;
terminal::enable_raw_mode()?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn restore_terminal(mut terminal: Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, crossterm::cursor::Show)?; // Mostrar el cursor al salir
terminal.show_cursor()?;
Ok(())
}
}
fn run_tui_app_counter() -> Result<(), Box<dyn std::error::Error>> {
use tui_counter_app::*;
let mut terminal = setup_terminal()?;
let app = App::new();
let res = run_app(&mut terminal, app);
restore_terminal(terminal)?;
res
}
// --- Nuevo módulo para la TUI de Tareas ---
mod tui_tasks_app {
use super::{TaskManager, Task};
use std::{io, time::{Duration, Instant}};
use crossterm::{event::{self, Event, KeyCode, KeyModifiers}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, execute};
use tui::{Terminal, Frame};
use tui::backend::CrosstermBackend;
use tui::widgets::{Block, Borders, Paragraph, List, ListItem, ListState, Clear};
use tui::layout::{Layout, Constraint, Direction, Rect};
use tui::style::{Style, Color, Modifier};
use tui::text::{Span, Spans};
pub struct App<'a> {
task_manager: TaskManager,
task_list_state: ListState,
input_text: String,
input_mode: InputMode,
pub should_quit: bool,
messages: Vec<(&'a str, Color)>, // Para mensajes de estado
message_timer: Option<Instant>,
}
#[derive(PartialEq)]
pub enum InputMode {
Normal,
Editing,
}
impl<'a> App<'a> {
pub fn new(task_manager: TaskManager) -> App<'a> {
let mut task_list_state = ListState::default();
if !task_manager.list_tasks().is_empty() {
task_list_state.select(Some(0));
}
App {
task_manager,
task_list_state,
input_text: String::new(),
input_mode: InputMode::Normal,
should_quit: false,
messages: Vec::new(),
message_timer: None,
}
}
pub fn on_key(&mut self, key: KeyCode, modifiers: KeyModifiers) {
match self.input_mode {
InputMode::Normal => match key {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Char('a') => {
self.input_mode = InputMode::Editing;
self.add_message("Modo edición: Añade una tarea y presiona Enter", Color::Green);
}
KeyCode::Char('t') => self.toggle_selected_task_completion(),
KeyCode::Char('d') => self.delete_selected_task(),
KeyCode::Up => self.prev_task(),
KeyCode::Down => self.next_task(),
_ => {},
},
InputMode::Editing => match key {
KeyCode::Enter => {
self.add_task_from_input();
self.input_mode = InputMode::Normal;
self.input_text.clear();
self.add_message("Tarea añadida!", Color::Green);
}
KeyCode::Char(c) => {
self.input_text.push(c);
}
KeyCode::Backspace => {
self.input_text.pop();
}
KeyCode::Esc => {
self.input_mode = InputMode::Normal;
self.input_text.clear();
self.add_message("Modo edición cancelado.", Color::Yellow);
}
_ => {},
},
}
}
fn add_task_from_input(&mut self) {
if !self.input_text.trim().is_empty() {
if let Err(e) = self.task_manager.add_task(self.input_text.trim().to_string()) {
self.add_message(&format!("Error al añadir tarea: {}", e), Color::Red);
} else {
self.add_message("Tarea añadida!", Color::Green);
// Seleccionar la nueva tarea
let new_index = self.task_manager.list_tasks().len() - 1;
self.task_list_state.select(Some(new_index));
}
}
}
fn next_task(&mut self) {
let i = match self.task_list_state.selected() {
Some(i) => {
if i >= self.task_manager.list_tasks().len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.task_list_state.select(Some(i));
}
fn prev_task(&mut self) {
let i = match self.task_list_state.selected() {
Some(i) => {
if i == 0 {
self.task_manager.list_tasks().len() - 1
} else {
i - 1
}
}
None => 0,
};
self.task_list_state.select(Some(i));
}
fn toggle_selected_task_completion(&mut self) {
if let Some(selected) = self.task_list_state.selected() {
if let Some(task) = self.task_manager.list_tasks().get(selected) {
if let Err(e) = self.task_manager.toggle_task_completion(task.id) {
self.add_message(&format!("Error al cambiar estado: {}", e), Color::Red);
} else {
self.add_message(&format!("Tarea {} actualizada.", task.id), Color::Blue);
}
}
}
}
fn delete_selected_task(&mut self) {
if let Some(selected) = self.task_list_state.selected() {
if let Some(task) = self.task_manager.list_tasks().get(selected) {
let task_id = task.id;
if let Err(e) = self.task_manager.delete_task(task_id) {
self.add_message(&format!("Error al eliminar tarea: {}", e), Color::Red);
} else {
self.add_message(&format!("Tarea {} eliminada.", task_id), Color::Red);
// Ajustar la selección después de eliminar
let num_tasks = self.task_manager.list_tasks().len();
if num_tasks == 0 {
self.task_list_state.select(None);
} else if selected >= num_tasks {
self.task_list_state.select(Some(num_tasks - 1));
} else {
// La selección se mantiene si aún es válida
}
}
}
}
}
pub fn add_message(&mut self, text: &'a str, color: Color) {
self.messages.push((text, color));
self.message_timer = Some(Instant::now());
}
pub fn clear_messages(&mut self) {
if let Some(timer) = self.message_timer {
if timer.elapsed() >= Duration::from_secs(5) {
self.messages.clear();
self.message_timer = None;
}
}
}
}
pub fn ui<B: CrosstermBackend>(f: &mut Frame<B>, app: &mut App) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[Constraint::Min(1), Constraint::Length(3), Constraint::Length(1)].as_ref()
)
.split(f.size());
// Sección de lista de tareas
let tasks: Vec<ListItem> = app.task_manager
.list_tasks()
.iter()
.map(|task| {
let status = if task.completed {
Span::styled("[X] ", Style::default().fg(Color::Green))
} else {
Span::styled("[ ] ", Style::default().fg(Color::White))
};
let content = Spans::from(vec![
status,
Span::styled(format!("{}: {}", task.id, task.description), Style::default().fg(Color::LightBlue))
]);
ListItem::new(content)
})
.collect();
let tasks_list = List::new(tasks)
.block(Block::default().borders(Borders::ALL).title("Tareas"))
.highlight_style(Style::default().bg(Color::LightBlue).fg(Color::Black))
.highlight_symbol("> ");
f.render_stateful_widget(tasks_list, main_chunks[0], &mut app.task_list_state);
// Sección de entrada (para añadir nuevas tareas)
let input = Paragraph::new(app.input_text.as_ref())
.style(match app.input_mode {
InputMode::Normal => Style::default().fg(Color::White),
InputMode::Editing => Style::default().fg(Color::Yellow),
})
.block(Block::default().borders(Borders::ALL).title("Input (A: Añadir, T: Toggle, D: Eliminar, Q: Salir)"));
f.render_widget(input, main_chunks[1]);
match app.input_mode {
InputMode::Normal => {},
InputMode::Editing => {
// Mover el cursor al final del texto de entrada
f.set_cursor(
main_chunks[1].x + app.input_text.len() as u16 + 1,
main_chunks[1].y + 1,
)
}
}
// Sección de mensajes de estado
let message_text: Vec<Span> = app.messages.iter()
.map(|(msg, color)| Span::styled(*msg, Style::default().fg(*color)))
.collect();
let status_messages = Paragraph::new(Spans::from(message_text))
.style(Style::default().fg(Color::Gray));
f.render_widget(status_messages, main_chunks[2]);
}
pub fn run_app<B: CrosstermBackend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
let tick_rate = Duration::from_millis(160); // ~6 FPS para TUI
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui(f, &mut app))?;
app.clear_messages(); // Limpiar mensajes antiguos
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
app.on_key(key.code, key.modifiers);
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
if app.should_quit {
return Ok(());
}
}
}
pub fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
execute!(io::stdout(), EnterAlternateScreen)?;
terminal::enable_raw_mode()?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn restore_terminal(mut terminal: Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, crossterm::cursor::Show)?;
terminal.show_cursor()?;
Ok(())
}
}
fn run_tui_tasks_app(task_manager: TaskManager) -> Result<(), Box<dyn std::error::Error>> {
use tui_tasks_app::*;
let mut terminal = setup_terminal()?;
let app = App::new(task_manager);
let res = run_app(&mut terminal, app);
restore_terminal(terminal)?;
res
}
Probando el Administrador de Tareas 🚀
- Añadir tareas desde la línea de comandos:
cargo run -- task add "Comprar leche"
cargo run -- task add "Programar CLI en Rust"
cargo run -- task add "Revisar emails"
- Listar tareas desde la línea de comandos:
cargo run -- task list
- Lanzar la interfaz TUI:
cargo run -- tui-tasks
Dentro de la TUI, podrás:
- Moverte con Flecha Arriba / Flecha Abajo.
- Presionar A para entrar en modo edición, escribir una tarea y Enter para añadirla.
- Presionar T para marcar/desmarcar la tarea seleccionada como completada.
- Presionar D para eliminar la tarea seleccionada.
- Presionar Q para salir.
Este ejemplo demuestra cómo clap define la interfaz inicial de la CLI, permitiendo subcomandos para operaciones directas (task add, task list) o para iniciar una experiencia interactiva rica (tui-tasks). La modularidad del código, separando la lógica TUI en su propio módulo, mejora la mantenibilidad.
Buenas Prácticas y Consejos Avanzados 💡
Desarrollar CLI robustas va más allá de solo implementar funciones; se trata de crear herramientas que sean fáciles de usar, mantener y extender.
Diseño Modular y Separación de Responsabilidades 🧱
- Separa la lógica de negocios de la lógica CLI/TUI: Tu lógica central (e.g.,
TaskManager) no debería depender declapotui-rs. Esto permite reutilizarla en otros contextos (APIs web, GUI, etc.) y facilita las pruebas. - Usa módulos: Divide tu código en módulos lógicos (e.g.,
cli_parser.rs,tui_app.rs,task_manager.rs) para mantenermain.rslimpio y manejable. - Manejo de errores: Utiliza el tipo
Resultde Rust consistentemente. Define tus propios tipos de error (thiserror,anyhow) para errores específicos de tu aplicación, mejorando la depuración y la experiencia del usuario.
Pruebas Unitarias e Integración ✅
- Pruebas unitarias para la lógica de negocios: Asegúrate de que tu
TaskManagery otras lógicas principales funcionen correctamente de forma aislada. - Pruebas de integración para la CLI: Puedes simular la ejecución de tu CLI con diferentes argumentos y verificar la salida.
assert_cmdes un crate excelente para esto.
// Ejemplo de test para TaskManager (en un archivo como `src/task_manager.rs` o `src/lib.rs`)
#[cfg(test)]
mod tests {
use super::TaskManager;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn test_add_and_list_tasks() -> Result<(), Box<dyn std::error::Error>> {
let temp_file = NamedTempFile::new()?;
let path = temp_file.path().to_str().unwrap();
let mut tm = TaskManager::new(path)?;
tm.add_task("Tarea 1".to_string())?;
tm.add_task("Tarea 2".to_string())?;
let tasks = tm.list_tasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].description, "Tarea 1");
assert_eq!(tasks[1].description, "Tarea 2");
Ok(())
}
#[test]
fn test_toggle_completion() -> Result<(), Box<dyn std::error::Error>> {
let temp_file = NamedTempFile::new()?;
let path = temp_file.path().to_str().unwrap();
let mut tm = TaskManager::new(path)?;
tm.add_task("Una tarea".to_string())?;
let task_id = tm.list_tasks()[0].id;
assert!(!tm.list_tasks()[0].completed);
tm.toggle_task_completion(task_id)?;
assert!(tm.list_tasks()[0].completed);
tm.toggle_task_completion(task_id)?;
assert!(!tm.list_tasks()[0].completed);
Ok(())
}
// Limpiar archivo temporal al final
}
Documentación y Ayuda 📚
clapgenera ayuda automáticamente: Aprovecha las descripciones de los campos (/// Doc comment) para queclapgenere una ayuda detallada y útil.- Documenta tu código: Los comentarios de documentación en Rust son excelentes para explicar la lógica interna y las APIs públicas.
Mejoras de Usabilidad en TUI 🧑💻
- Manejo de eventos: Considera el manejo de eventos de ratón si tu TUI es más compleja.
- Paginación/Scroll: Para listas largas, implementa desplazamiento y paginación.
- Temas/Colores: Permite personalizar los colores de la TUI para diferentes usuarios.
- Feedback al usuario: Mensajes de estado claros, indicadores de carga, etc., son cruciales para una buena UX.
Aquí hay una tabla comparativa de los enfoques para interfaces de terminal:
| Característica | CLI (Argumentos clap) | TUI (tui-rs) |
|---|---|---|
| --- | --- | --- |
| Interacción | Un solo comando/ejecución | Interactiva, en tiempo real |
| Complejidad | Simple, para tareas específicas | Puede ser muy rica y dinámica |
| --- | --- | --- |
| Curva de Aprendizaje | Baja | Moderada a Alta |
| Uso de Recursos | Bajo | Moderado (mantiene el estado) |
| --- | --- | --- |
| Casos de Uso | Automatización, scripts, tareas puntuales | Monitores, editores, dashboards interactivos |
| Feedback Visual | Salida de texto, códigos de error | Widgets, colores, disposición dinámica |
Conclusión 🎉
Has llegado al final de este tutorial sobre el desarrollo de CLI robustas en Rust. Hemos explorado cómo clap nos permite crear interfaces de línea de comandos estructuradas y cómo tui-rs abre las puertas a experiencias de usuario interactivas y visualmente atractivas directamente en la terminal.
Combinando estas poderosas herramientas con las características de seguridad y rendimiento de Rust, tienes todo lo necesario para construir herramientas de línea de comandos de alta calidad que sean eficientes, fiables y un placer de usar.
Recuerda la importancia de un buen diseño, la modularidad del código y las pruebas exhaustivas para asegurar la robustez de tus aplicaciones CLI. ¡Ahora es tu turno de construir la próxima gran herramienta para la línea de comandos con Rust!
Tutoriales relacionados
- Macros en Rust: Automatizando Código con Declarativas y Procedurales 🛠️advanced18 min
- Gestionando el Estado en Aplicaciones Rust: Patrones con Smart Pointers y Celdas de Referencia 🛡️intermediate18 min
- Async/Await en Rust: Concurrencia Robusta con Tokio y Futures ⚡intermediate20 min
- Gestionando la Configuración en Aplicaciones Rust: `config` y `dotenv` para un Desarrollo Flexible ⚙️intermediate15 min
- Diseño de APIs Robustas en Rust: El Arte de la Interfaz y la Implementación 🛡️intermediate15 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!