En esta página

Proyecto final: CLI de notas en Rust

25 min lectura TextoCap. 5 — Rust en práctica

Proyecto final: CLI de notas en Rust

En esta lección final integramos todo lo aprendido en el curso para construir una aplicación de gestión de notas en línea de comandos. El proyecto demuestra cómo los conceptos de Rust se combinan en una arquitectura real y mantenible.

Conceptos integrados

Concepto Lección Uso en el proyecto
Structs L7 Nota, GestorNotas, AlmacenamientoMemoria
Enums L8 AppError, Prioridad
Traits L10 Almacenable con implementaciones múltiples
Generics L11 impl Almacenable + 'static
Result + ? L12 Toda la cadena de manejo de errores
Módulos L13 Organización por responsabilidad
Iteradores L14 buscar(), por_prioridad(), estadisticas()
Concurrencia L15 Arc<Mutex<>>, thread de auto-guardado
Ownership L4 Transferencia de Box<dyn Almacenable>
Borrowing L5 Referencias en métodos de solo lectura

Arquitectura del proyecto

notas-cli/
├── Cargo.toml
└── src/
    ├── main.rs           # Punto de entrada + orquestación
    ├── error.rs          # AppError centralizado
    ├── modelos/
    │   ├── mod.rs
    │   ├── nota.rs       # Struct Nota + enum Prioridad
    │   └── gestor.rs     # GestorNotas
    └── almacenamiento/
        ├── mod.rs        # Trait Almacenable
        ├── memoria.rs    # Implementación en memoria
        └── archivo.rs    # Implementación con archivos (extensión)

Análisis del diseño

El trait `Almacenable`

trait Almacenable {
    fn guardar(&self, notas: &[Nota]) -> Result<(), AppError>;
    fn cargar(&self) -> Result<Vec<Nota>, AppError>;
}

Este trait define el contrato de persistencia sin especificar el mecanismo. La implementación AlmacenamientoMemoria usa un HashMap en un Mutex. Una implementación de archivo real usaría std::fs.

`Box`

struct GestorNotas {
    almacenamiento: Box<dyn Almacenable>,
}

El Box<dyn Almacenable> es un trait object — permite al GestorNotas trabajar con cualquier implementación sin conocer el tipo concreto. Esto es dispatch dinámico: más flexible que generics cuando necesitas intercambiar implementaciones en tiempo de ejecución.

Arc> para el auto-guardado

let gestor_compartido = Arc::new(Mutex::new(gestor));
let _auto_save = iniciar_auto_guardado(Arc::clone(&gestor_compartido), Duration::from_secs(30));

El thread de auto-guardado comparte el gestor con el thread principal usando Arc<Mutex<>>. El Arc permite múltiples owners (thread-safe), el Mutex garantiza acceso exclusivo.

Extensiones posibles

1. Persistencia con archivos

use std::fs;

struct AlmacenamientoArchivo { ruta: String }

impl Almacenable for AlmacenamientoArchivo {
    fn guardar(&self, notas: &[Nota]) -> Result<(), AppError> {
        let json = serde_json::to_string_pretty(notas)
            .map_err(|e| AppError::AlmacenamientoFallido(e.to_string()))?;
        fs::write(&self.ruta, json)
            .map_err(|e| AppError::AlmacenamientoFallido(e.to_string()))
    }
    
    fn cargar(&self) -> Result<Vec<Nota>, AppError> {
        if !std::path::Path::new(&self.ruta).exists() {
            return Ok(Vec::new());
        }
        let contenido = fs::read_to_string(&self.ruta)
            .map_err(|e| AppError::AlmacenamientoFallido(e.to_string()))?;
        serde_json::from_str(&contenido)
            .map_err(|e| AppError::AlmacenamientoFallido(e.to_string()))
    }
}

2. Comandos CLI con clap

[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "notas")]
struct Cli {
    #[command(subcommand)]
    comando: Comando,
}

#[derive(Subcommand)]
enum Comando {
    Agregar { titulo: String, contenido: String },
    Listar,
    Buscar { termino: String },
    Eliminar { id: u64 },
}

3. Ordenación avanzada con múltiples criterios

fn ordenar_notas<'a>(
    notas: &'a [Nota],
    criterio: &str,
    ascendente: bool,
) -> Vec<&'a Nota> {
    let mut resultado: Vec<&Nota> = notas.iter().collect();
    
    match criterio {
        "fecha"     => resultado.sort_by_key(|n| n.creada_en),
        "titulo"    => resultado.sort_by(|a, b| a.titulo.cmp(&b.titulo)),
        "prioridad" => resultado.sort_by(|a, b| {
            let peso = |p: &Prioridad| match p {
                Prioridad::Alta => 0,
                Prioridad::Media => 1,
                Prioridad::Baja => 2,
            };
            peso(&a.prioridad).cmp(&peso(&b.prioridad))
        }),
        _ => {}
    }
    
    if !ascendente {
        resultado.reverse();
    }
    
    resultado
}

¡Felicitaciones!

Has completado el curso Rust desde Cero. Ahora dominas:

  • El sistema de tipos único de Rust: ownership, borrowing, lifetimes
  • Tipos compuestos: structs, enums, pattern matching exhaustivo
  • Colecciones de la std: Vec, String, HashMap
  • Abstracciones: traits, generics, monomorphization
  • Manejo de errores idiomático con Result y ?
  • El ecosistema: módulos, crates, Cargo
  • Programación funcional: closures, iteradores lazy
  • Concurrencia segura: threads, Arc, Mutex, canales

Próximos pasos

  1. Async Rust: Estudia tokio y async/await para I/O no bloqueante
  2. Web con Axum o Actix-web: Construye APIs REST en Rust
  3. WebAssembly: Compila Rust a WASM con wasm-pack
  4. Embedded Rust: Programa microcontroladores con Rust no_std
  5. The Rust Programming Language: El libro oficial en doc.rust-lang.org/book
  6. Rustlings: Ejercicios interactivos en github.com/rust-lang/rustlings

Rust recompensa la paciencia. El borrow checker puede frustrarte al principio, pero una vez que tu código compila, generalmente es correcto. ¡Bienvenido a la comunidad Rust!

Extiende el proyecto
Desafíos adicionales: implementa persistencia real con std::fs::write y serde_json, añade una interfaz TUI con la crate crossterm, implementa búsqueda por fecha con chrono, agrega soporte para notas en Markdown, o convierte el gestor en un servidor HTTP con actix-web.
Arquitectura del proyecto
El proyecto demuestra: tipos de error con Display + Error, traits para abstracción de persistencia (Almacenable), Box<dyn Trait> para polimorfismo dinámico, Arc + Mutex para concurrencia, iteradores para búsqueda y filtrado, y Result con ? para propagación de errores.
rust
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

// === Tipos de error personalizados ===
#[derive(Debug)]
enum AppError {
    NotaNoEncontrada(u64),
    AlmacenamientoFallido(String),
    EntradaInvalida(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NotaNoEncontrada(id) => write!(f, "Nota #{id} no encontrada"),
            Self::AlmacenamientoFallido(e) => write!(f, "Error de almacenamiento: {e}"),
            Self::EntradaInvalida(e) => write!(f, "Entrada inválida: {e}"),
        }
    }
}

impl std::error::Error for AppError {}

// === Modelos ===
#[derive(Debug, Clone, PartialEq)]
enum Prioridad { Baja, Media, Alta }

impl fmt::Display for Prioridad {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Prioridad::Baja  => write!(f, "Baja"),
            Prioridad::Media => write!(f, "Media"),
            Prioridad::Alta  => write!(f, "Alta"),
        }
    }
}

#[derive(Debug, Clone)]
struct Nota {
    id: u64,
    titulo: String,
    contenido: String,
    etiquetas: Vec<String>,
    prioridad: Prioridad,
    creada_en: u64,
}

impl Nota {
    fn nueva(titulo: &str, contenido: &str, prioridad: Prioridad) -> Self {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        Nota {
            id: timestamp,
            titulo: titulo.to_string(),
            contenido: contenido.to_string(),
            etiquetas: Vec::new(),
            prioridad,
            creada_en: timestamp,
        }
    }

    fn agregar_etiqueta(&mut self, etiqueta: &str) {
        let e = etiqueta.trim().to_lowercase();
        if !e.is_empty() && !self.etiquetas.contains(&e) {
            self.etiquetas.push(e);
        }
    }
}

impl fmt::Display for Nota {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[#{}] {} [{}]", self.id, self.titulo, self.prioridad)?;
        if !self.etiquetas.is_empty() {
            write!(f, " — {:?}", self.etiquetas)?;
        }
        Ok(())
    }
}

// === Trait: persistencia ===
trait Almacenable {
    fn guardar(&self, notas: &[Nota]) -> Result<(), AppError>;
    fn cargar(&self) -> Result<Vec<Nota>, AppError>;
}

// Implementación en memoria (simulada)
struct AlmacenamientoMemoria {
    datos: Arc<Mutex<HashMap<u64, Nota>>>,
}

impl AlmacenamientoMemoria {
    fn nuevo() -> Self {
        AlmacenamientoMemoria {
            datos: Arc::new(Mutex::new(HashMap::new())),
        }
    }
}

impl Almacenable for AlmacenamientoMemoria {
    fn guardar(&self, notas: &[Nota]) -> Result<(), AppError> {
        let mut mapa = self.datos.lock()
            .map_err(|e| AppError::AlmacenamientoFallido(e.to_string()))?;
        mapa.clear();
        for nota in notas {
            mapa.insert(nota.id, nota.clone());
        }
        Ok(())
    }

    fn cargar(&self) -> Result<Vec<Nota>, AppError> {
        let mapa = self.datos.lock()
            .map_err(|e| AppError::AlmacenamientoFallido(e.to_string()))?;
        let mut notas: Vec<Nota> = mapa.values().cloned().collect();
        notas.sort_by_key(|n| n.creada_en);
        Ok(notas)
    }
}

// === Gestor de notas ===
struct GestorNotas {
    notas: Vec<Nota>,
    almacenamiento: Box<dyn Almacenable>,
}

impl GestorNotas {
    fn nuevo(almacenamiento: impl Almacenable + 'static) -> Result<Self, AppError> {
        let notas = almacenamiento.cargar()?;
        Ok(GestorNotas { notas, almacenamiento: Box::new(almacenamiento) })
    }

    fn agregar(&mut self, nota: Nota) -> Result<u64, AppError> {
        let id = nota.id;
        self.notas.push(nota);
        self.almacenamiento.guardar(&self.notas)?;
        Ok(id)
    }

    fn obtener(&self, id: u64) -> Option<&Nota> {
        self.notas.iter().find(|n| n.id == id)
    }

    fn eliminar(&mut self, id: u64) -> Result<(), AppError> {
        let pos = self.notas.iter().position(|n| n.id == id)
            .ok_or(AppError::NotaNoEncontrada(id))?;
        self.notas.remove(pos);
        self.almacenamiento.guardar(&self.notas)
    }

    fn buscar(&self, termino: &str) -> Vec<&Nota> {
        let termino = termino.to_lowercase();
        self.notas.iter()
            .filter(|n| {
                n.titulo.to_lowercase().contains(&termino)
                || n.contenido.to_lowercase().contains(&termino)
                || n.etiquetas.iter().any(|e| e.contains(&termino))
            })
            .collect()
    }

    fn por_prioridad(&self, prioridad: &Prioridad) -> Vec<&Nota> {
        self.notas.iter()
            .filter(|n| &n.prioridad == prioridad)
            .collect()
    }

    fn estadisticas(&self) -> HashMap<String, usize> {
        let mut stats = HashMap::new();
        stats.insert("total".to_string(), self.notas.len());
        stats.insert("alta".to_string(),  self.por_prioridad(&Prioridad::Alta).len());
        stats.insert("media".to_string(), self.por_prioridad(&Prioridad::Media).len());
        stats.insert("baja".to_string(),  self.por_prioridad(&Prioridad::Baja).len());

        let total_etiquetas: usize = self.notas.iter()
            .map(|n| n.etiquetas.len())
            .sum();
        stats.insert("etiquetas".to_string(), total_etiquetas);
        stats
    }
}

// === Auto-guardado en background thread ===
fn iniciar_auto_guardado(
    gestor: Arc<Mutex<GestorNotas>>,
    intervalo: Duration,
) -> thread::JoinHandle<()> {
    thread::spawn(move || {
        loop {
            thread::sleep(intervalo);
            if let Ok(g) = gestor.lock() {
                if g.almacenamiento.guardar(&g.notas).is_ok() {
                    println!("[Auto-guardado OK]");
                }
            }
        }
    })
}

fn main() -> Result<(), AppError> {
    println!("=== CLI de Notas en Rust ===\n");

    let almacen = AlmacenamientoMemoria::nuevo();
    let mut gestor = GestorNotas::nuevo(almacen)?;

    // Crear notas
    let mut n1 = Nota::nueva("Aprender Rust", "Completar el curso de Rust desde cero", Prioridad::Alta);
    n1.agregar_etiqueta("rust");
    n1.agregar_etiqueta("aprendizaje");

    let mut n2 = Nota::nueva("Comprar víveres", "Leche, pan, queso, frutas", Prioridad::Media);
    n2.agregar_etiqueta("personal");

    let mut n3 = Nota::nueva("Revisar PR", "Revisar el pull request #42 del equipo", Prioridad::Alta);
    n3.agregar_etiqueta("trabajo");
    n3.agregar_etiqueta("rust");

    let mut n4 = Nota::nueva("Leer artículo", "Blog post sobre ownership y lifetimes", Prioridad::Baja);
    n4.agregar_etiqueta("rust");
    n4.agregar_etiqueta("lectura");

    let id1 = gestor.agregar(n1)?;
    let _id2 = gestor.agregar(n2)?;
    let _id3 = gestor.agregar(n3)?;
    let _id4 = gestor.agregar(n4)?;

    // Mostrar todas las notas
    println!("--- Todas las notas ---");
    let notas = gestor.almacenamiento.cargar()?;
    for nota in &notas {
        println!("  {nota}");
    }

    // Buscar
    println!("\n--- Búsqueda: 'rust' ---");
    for nota in gestor.buscar("rust") {
        println!("  {nota}");
    }

    // Por prioridad
    println!("\n--- Prioridad Alta ---");
    for nota in gestor.por_prioridad(&Prioridad::Alta) {
        println!("  {}", nota.titulo);
    }

    // Obtener una nota específica
    println!("\n--- Nota #{id1} ---");
    if let Some(nota) = gestor.obtener(id1) {
        println!("  Título: {}", nota.titulo);
        println!("  Contenido: {}", nota.contenido);
        println!("  Etiquetas: {:?}", nota.etiquetas);
    }

    // Estadísticas
    println!("\n--- Estadísticas ---");
    let stats = gestor.estadisticas();
    let mut claves: Vec<&String> = stats.keys().collect();
    claves.sort();
    for clave in claves {
        println!("  {clave}: {}", stats[clave]);
    }

    // Eliminar una nota
    gestor.eliminar(id1)?;
    println!("\nNota #{id1} eliminada. Total: {}", gestor.notas.len());

    println!("\n¡Proyecto completado! Rust es fearlessly concurrent.");
    Ok(())
}