En esta página
Proyecto final: CLI de notas en Rust
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
- Async Rust: Estudia
tokioyasync/awaitpara I/O no bloqueante - Web con Axum o Actix-web: Construye APIs REST en Rust
- WebAssembly: Compila Rust a WASM con
wasm-pack - Embedded Rust: Programa microcontroladores con Rust
no_std - The Rust Programming Language: El libro oficial en doc.rust-lang.org/book
- 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!
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 ¬as {
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(())
}
Inicia sesión para guardar tu progreso