Construye un CLI de TODOs en Rust con persistencia SQLite

rust Construye un CLI de TODOs en Rust con persistencia SQLite

Construye un CLI de TODOs en Rust con persistencia SQLite

Proyecto práctico: un cliente de línea de comandos para gestionar tareas (add / list / done / remove) con persistencia en SQLite usando rusqlite y clap. Enfócate en código claro, uso de prepared statements y un diseño modular.

Requisitos previos

  • Rust toolchain (rustc + cargo)
  • SQLite (no obligatorio si usas la característica bundled de rusqlite)
  • Conocimientos básicos de Rust: módulos, Result, borrowing

Características

  • Agregar tarea: todo add "Comprar leche"
  • Listar tareas: todo list
  • Marcar tarea como hecha: todo done 3
  • Eliminar tarea: todo remove 3
  • DB en ~/.todo.db por defecto, configurable vía variable de entorno TODO_DB

Estructura de carpetas

todo_cli/
├─ Cargo.toml
└─ src/
   ├─ main.rs
   └─ db.rs

Cargo.toml

[package]
name = "todo_cli"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
rusqlite = { version = "0.29", features = ["bundled"] }
chrono = "0.4"
dirs = "4"

Notas sobre dependencias:

  • clap para el parsing de la CLI (derive).
  • rusqlite para SQLite: uso la característica bundled para evitar depender de una instalación externa de SQLite (útil en tutoriales / pruebas).
  • chrono para timestamps RFC3339.
  • dirs para resolver ~ y colocar la DB en el home del usuario.

src/db.rs

use rusqlite::{params, Connection, Result};
use chrono::Utc;

pub fn init(conn: &Connection) -> Result<()> {
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            done INTEGER NOT NULL DEFAULT 0,
            created_at TEXT NOT NULL
        );",
    )?;
    Ok(())
}

pub fn add_task(conn: &Connection, title: &str) -> Result {
    let now = Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO tasks (title, done, created_at) VALUES (?1, 0, ?2)",
        params![title, now],
    )?;
    Ok(conn.last_insert_rowid())
}

pub fn list_tasks(conn: &Connection) -> Result> {
    let mut stmt = conn.prepare("SELECT id, title, done, created_at FROM tasks ORDER BY id DESC")?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, i64>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, i64>(2)? != 0,
            row.get::<_, String>(3)?,
        ))
    })?;

    let mut items = Vec::new();
    for r in rows {
        items.push(r?);
    }
    Ok(items)
}

pub fn mark_done(conn: &Connection, id: i64) -> Result {
    let affected = conn.execute("UPDATE tasks SET done = 1 WHERE id = ?1", params![id])?;
    Ok(affected)
}

pub fn remove_task(conn: &Connection, id: i64) -> Result {
    let affected = conn.execute("DELETE FROM tasks WHERE id = ?1", params![id])?;
    Ok(affected)
}

src/main.rs

mod db;

use clap::{Parser, Subcommand};
use rusqlite::Connection;
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "todo")]
#[command(about = "CLI simple de TODOs en Rust + SQLite")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Añade una tarea
    Add { title: String },
    /// Lista las tareas
    List,
    /// Marca una tarea como hecha
    Done { id: i64 },
    /// Elimina una tarea
    Remove { id: i64 },
}

fn db_path_from_env_or_default() -> PathBuf {
    if let Ok(p) = std::env::var("TODO_DB") {
        return PathBuf::from(p);
    }
    if let Some(home) = dirs::home_dir() {
        return home.join(".todo.db");
    }
    PathBuf::from("./todo.db")
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    let db_path = db_path_from_env_or_default();
    let conn = Connection::open(&db_path)?;
    db::init(&conn)?;

    match cli.command {
        Commands::Add { title } => {
            let id = db::add_task(&conn, &title)?;
            println!("Tarea creada con id {}", id);
        }
        Commands::List => {
            let tasks = db::list_tasks(&conn)?;
            if tasks.is_empty() {
                println!("No hay tareas.");
            } else {
                for (id, title, done, created) in tasks {
                    let status = if done { "✔" } else { " " };
                    println!("[{}] {}: {} (creada {})", status, id, title, created);
                }
            }
        }
        Commands::Done { id } => {
            let affected = db::mark_done(&conn, id)?;
            if affected == 0 {
                println!("No se encontró tarea con id {}", id);
            } else {
                println!("Tarea {} marcada como hecha", id);
            }
        }
        Commands::Remove { id } => {
            let affected = db::remove_task(&conn, id)?;
            if affected == 0 {
                println!("No se encontró tarea con id {}", id);
            } else {
                println!("Tarea {} eliminada", id);
            }
        }
    }

    Ok(())
}

Por qué se diseña así (el razonamiento)

  • Modularización: separar db.rs de main.rs facilita tests unitarios y mantenimiento.
  • rusqlite + prepared statements: evita inyección SQL y mejora rendimiento para operaciones repetidas.
  • SQLite hace el proyecto reproducible y fácil de probar localmente; usar la opción bundled simplifica entornos educativos.
  • Default en ~/.todo.db: permite persistencia entre ejecuciones sin configurar nada. La variable TODO_DB permite redirigir la DB para pruebas.
  • Uso de timestamps RFC3339 (via chrono) para compatibilidad y trazabilidad.

Mejoras posibles (siguientes pasos)

  • Añadir filtros en list (solo pendientes, buscar por texto).
  • Soportar prioridad o etiquetas (columnas extra y escapes).
  • Migraciones: mover esquema a archivos SQL y usar un migrator (o implementar versiones de esquema en código).
  • Pruebas unitarias para la capa db.rs usando una DB en memoria (sqlite:::memory:).
  • Probar una versión async con sqlx y un servicio HTTP si necesitas sincronización remota.

Consejo avanzado: si vas a sincronizar este fichero en la nube o usarlo en equipos, ten cuidado con el bloqueo de SQLite; para concurrencia real y múltiples clientes simultáneos es mejor migrar a un servidor SQL (Postgres) o a una capa que gestione sincronización. También considera cifrar el archivo de base de datos si almacenas datos sensibles (no lo maneja SQLite por defecto).

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión