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
bundledde 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.dbpor defecto, configurable vía variable de entornoTODO_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:
clappara el parsing de la CLI (derive).rusqlitepara SQLite: uso la característicabundledpara evitar depender de una instalación externa de SQLite (útil en tutoriales / pruebas).chronopara timestamps RFC3339.dirspara 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.rsdemain.rsfacilita 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
bundledsimplifica entornos educativos. - Default en
~/.todo.db: permite persistencia entre ejecuciones sin configurar nada. La variableTODO_DBpermite 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.rsusando una DB en memoria (sqlite:::memory:). - Probar una versión async con
sqlxy 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).
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación