Ir al contenido

Proyecto: Lista de tareas en Rust

En este proyecto vas a construir una pequeña lista de tareas que vive en tu terminal. Escribes todo add Comprar leche y la recuerda. Cierras la terminal, vuelves mañana, escribes todo list, y tus tareas siguen ahí.

Es un segundo proyecto genial porque se parece a una herramienta de verdad. Usa structs (estructuras), enums (enumeraciones), vectores, argumentos de línea de comandos, lectura y escritura de archivos y Result con ?. Construiremos cada pieza por separado, la probaremos en el Playground y al final lo juntaremos todo.

Así se ve la herramienta terminada cuando la usas:

Terminal window
$ cargo run -- add Comprar leche
Tarea 1 agregada.
$ cargo run -- add Llamar a la abuela
Tarea 2 agregada.
$ cargo run -- done 1
Tarea 1 marcada como hecha.
$ cargo run -- list
[x] 1: Comprar leche
[ ] 2: Llamar a la abuela
$ cargo run -- remove 1
Tarea 1 eliminada: Comprar leche

Todo lo que va después de -- se le pasa a tu programa, no a Cargo. Los nombres de los comandos (add, list, done y remove) los dejamos en inglés, como en la mayoría de las herramientas de terminal. Si prefieres agregar o listar, basta con cambiarlos en parse_command (Paso 2).

Las tareas se guardan en un archivo llamado tasks.txt, una tarea por línea:

2|0|Llamar a la abuela

Cada línea tiene la forma id|done|text, donde done vale 1 si la tarea está terminada y 0 si no.

Empieza un proyecto nuevo con:

Terminal window
cargo new todo
cd todo

Cada tarea tiene un número (para poder referirte a ella), una marca de “hecha” y un texto. Un struct encaja perfecto. También le damos dos métodos: uno para convertir una tarea en una línea de texto para el archivo, y otro para leer una tarea a partir de una línea.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Convierte una tarea en una línea, como "1|0|Comprar leche".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Lee una tarea desde una línea. Devuelve None si la línea está rota.
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
fn main() {
let task = Task { id: 1, done: false, text: String::from("Comprar leche") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Llamar a la abuela"));
println!("{:?}", Task::from_line("3|0|Leer sobre pruebas A|B"));
println!("{:?}", Task::from_line("esta línea está rota"));
}

Salida:

1|0|Comprar leche
Some(Task { id: 2, done: true, text: "Llamar a la abuela" })
Some(Task { id: 3, done: false, text: "Leer sobre pruebas A|B" })
None
▶ Pruébalo tú mismo

Vale la pena detenerse en dos detalles:

  • splitn(3, '|') divide en como máximo 3 trozos. Los dos primeros | separan los campos, y todo lo que viene después es el texto, aunque el texto tenga un | dentro. Con un simple split('|'), la tarea “Leer sobre pruebas A|B” perdería la mitad de su texto.
  • from_line devuelve un Option y usa ? en cada paso. Si falta alguna pieza o el id no es un número, toda la función devuelve None. El .ok() convierte el Result de parse() en un Option para que ? pueda trabajar con él.

La herramienta entiende cuatro comandos. Cada uno es una variante de un enum, y algunas variantes llevan datos: Add necesita el texto, y Done y Remove necesitan un número de tarea.

#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("indica un número de tarea"))?;
arg.parse().map_err(|_| format!("'{arg}' no es un número de tarea"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("uso: todo add <texto de la tarea>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("comando desconocido '{other}'")),
}
}
fn main() {
let examples = [
vec!["add", "Comprar", "leche"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "dos"],
vec!["remove"],
vec!["bailar"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Salida:

["add", "Comprar", "leche"] -> Ok(Add("Comprar leche"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "dos"] -> Err("'dos' no es un número de tarea")
["remove"] -> Err("indica un número de tarea")
["bailar"] -> Err("comando desconocido 'bailar'")
▶ Pruébalo tú mismo

En el programa real, args viene de la línea de comandos. Como viste en la página de Argumentos de línea de comandos, el primer elemento de std::env::args() es el nombre del propio programa, así que lo saltamos:

let args: Vec<String> = std::env::args().skip(1).collect();

¿Por qué add usa args[1..].join(" ")? Tu shell divide todo add Comprar leche en palabras sueltas: add, Comprar, leche. Al unir todo lo que viene después de add, el texto de la tarea vuelve a quedar entero, y no necesitas comillas.

Fíjate en que parse_command nunca se rompe. Una entrada incorrecta se convierte en un Err con un mensaje útil. Quien llama a la función decide qué hacer con él.

Todas las tareas viven en un Vec<Task>. Cada comando es una función pequeña que modifica el vector:

  • Agregar: busca el id más grande hasta ahora, súmale 1 y agrega una tarea nueva con push.
  • Marcar como hecha: busca la tarea con ese id y pon done = true.
  • Eliminar: busca la posición de la tarea y quítala.
  • Listar: muestra todas las tareas.
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("no existe la tarea {id}")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("no existe la tarea {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("No hay nada pendiente. ¡Disfruta tu día!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn main() {
let mut tasks = Vec::new();
add_task(&mut tasks, String::from("Comprar leche"));
add_task(&mut tasks, String::from("Llamar a la abuela"));
add_task(&mut tasks, String::from("Aprender Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Eliminada: {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Error: {message}");
}
print_tasks(&tasks);
}

Salida:

Eliminada: Comprar leche
Error: no existe la tarea 99
[ ] 2: Llamar a la abuela
[x] 3: Aprender Rust
▶ Pruébalo tú mismo

Algunas decisiones son a propósito:

  • Los ids salen del id más grande que ya existe, no de tasks.len() + 1. Si eliminas la tarea 1 y luego agregas una nueva, usar la longitud te daría un id repetido.
  • mark_done recibe &mut [Task] (un slice, o porción) porque solo modifica elementos. add_task y remove_task reciben &mut Vec<Task> porque cambian la longitud.
  • Los errores son Result<_, String>. Un error de tipo String es lo más sencillo que funciona. Para programas más grandes, mira Errores personalizados.

iter_mut().find(...) te da una referencia mutable a la tarea que coincide; por eso task.done = true cambia la tarea que está dentro del vector. En Iteradores en Rust encontrarás más sobre find y position.

Ahora las tareas tienen que sobrevivir cuando el programa termina. Las guardamos con std::fs::write y las cargamos con std::fs::read_to_string. Las dos pueden fallar, así que las dos devuelven Result, y el operador ? pasa cualquier error hacia arriba, a quien llamó a la función:

use std::fs;
use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// La primera vez que ejecutas el programa, todavía no hay archivo.
// Eso no es un error: solo significa "no hay tareas".
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}

El caso NotFound importa más de lo que parece. Sin él, el primer todo add fallaría con “No such file or directory” (“no existe el archivo o directorio”, un mensaje del sistema que sale en inglés), porque todavía no has guardado nada. Cualquier otro error (como “permission denied”, “permiso denegado”) se sigue pasando hacia arriba.

filter_map(Task::from_line) lee cada línea y se queda solo con las que se pueden interpretar. Ten en cuenta que así se saltan en silencio las líneas rotas. Es cómodo si alguien edita el archivo a mano y se equivoca al escribir, pero también significa que un problema podría pasar desapercibido. Más abajo, entre las ideas para ampliarlo, está avisar de esas líneas.

Mira Archivos en Rust para saber más sobre leer y escribir archivos.

Aquí está todo junto. Ponlo en src/main.rs y ejecútalo con cargo run -- add Comprar leche, cargo run -- list, etc.

La lógica principal está en run_command, que trabaja con un Vec<Task> y no sabe nada de archivos. main solo carga el archivo, llama a run_command y guarda. Mantener esas partes separadas hace que la lógica sea fácil de probar, y es lo que nos permite crear una versión para el Playground en el siguiente paso.

use std::env;
use std::error::Error;
use std::fs;
use std::io;
use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("indica un número de tarea"))?;
arg.parse().map_err(|_| format!("'{arg}' no es un número de tarea"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("uso: todo add <texto de la tarea>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("comando desconocido '{other}'")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("no existe la tarea {id}")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("no existe la tarea {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("No hay nada pendiente. ¡Disfruta tu día!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("Tarea {id} agregada.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tarea {id} marcada como hecha.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tarea {id} eliminada: {}", task.text);
}
}
Ok(())
}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}
fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = load_tasks(FILE)?;
run_command(&args, &mut tasks)?;
save_tasks(FILE, &tasks)?;
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("Error: {e}");
process::exit(1);
}
}

Una nota sobre run() -> Result<(), Box<dyn Error>>. Nuestras funciones devuelven dos tipos de error distintos: io::Error en las funciones de archivos y String en las de comandos. Box<dyn Error> es una caja que puede guardar cualquier tipo de error, así que ? funciona con los dos. Después, main muestra el error con eprintln! (que escribe en la salida de errores) y termina con el código 1, la señal habitual de que algo salió mal.

Paso 6: Una versión que puedes ejecutar en el Playground

Sección titulada «Paso 6: Una versión que puedes ejecutar en el Playground»

El Playground no puede pasar argumentos de línea de comandos, y no queremos que dependa de archivos. Así que esta versión usa el mismo run_command, pero le da una lista de comandos ya escrita y al final muestra lo que contendría tasks.txt:

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("indica un número de tarea"))?;
arg.parse().map_err(|_| format!("'{arg}' no es un número de tarea"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("uso: todo add <texto de la tarea>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("comando desconocido '{other}'")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("no existe la tarea {id}")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("no existe la tarea {id}")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("No hay nada pendiente. ¡Disfruta tu día!");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("Tarea {id} agregada.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tarea {id} marcada como hecha.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tarea {id} eliminada: {}", task.text);
}
}
Ok(())
}
fn main() {
// Cada línea es lo que escribirías después de `todo`.
let script = [
"list",
"add Comprar leche",
"add Llamar a la abuela",
"add Aprender Rust",
"done 3",
"remove 1",
"done 9",
"saltar",
"list",
];
let mut tasks: Vec<Task> = Vec::new();
for line in script {
println!("$ todo {line}");
let args: Vec<String> = line.split_whitespace().map(String::from).collect();
if let Err(message) = run_command(&args, &mut tasks) {
println!("Error: {message}");
}
}
println!();
println!("tasks.txt contendría:");
for task in &tasks {
println!("{}", task.to_line());
}
}

Salida:

$ todo list
No hay nada pendiente. ¡Disfruta tu día!
$ todo add Comprar leche
Tarea 1 agregada.
$ todo add Llamar a la abuela
Tarea 2 agregada.
$ todo add Aprender Rust
Tarea 3 agregada.
$ todo done 3
Tarea 3 marcada como hecha.
$ todo remove 1
Tarea 1 eliminada: Comprar leche
$ todo done 9
Error: no existe la tarea 9
$ todo saltar
Error: comando desconocido 'saltar'
$ todo list
[ ] 2: Llamar a la abuela
[x] 3: Aprender Rust
tasks.txt contendría:
2|0|Llamar a la abuela
3|1|Aprender Rust
▶ Pruébalo tú mismo

Cambia el guion de comandos y vuelve a ejecutarlo. Prueba a agregar una tarea después de eliminar otra, y comprueba que el id nuevo no se repite.

Nuestro formato id|done|text es fácil de leer, pero tuviste que escribir to_line y from_line a mano. A medida que tus tareas crecen (fechas límite, prioridades, etiquetas), ese código hecho a mano también crece.

En los proyectos reales, lo normal es dejar ese trabajo a los crates (paquetes de Rust) de serde. Agrégalos con:

Terminal window
cargo add serde --features derive
cargo add serde_json

Después, una sola línea derive te da el guardado y la carga gratis:

use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn main() {
let tasks = vec![
Task { id: 1, done: false, text: String::from("Comprar leche") },
Task { id: 2, done: true, text: String::from("Llamar a la abuela") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("Se volvieron a cargar {} tareas", loaded.len());
}

Salida:

[{"id":1,"done":false,"text":"Comprar leche"},{"id":2,"done":true,"text":"Llamar a la abuela"}]
Se volvieron a cargar 2 tareas
▶ Pruébalo tú mismo

Esa cadena JSON la escribirías en tasks.json con fs::write, igual que antes. La página JSON con Serde lo explica en detalle.

Usar ? en un main que no devuelve nada. Es tentador escribir esto:

use std::fs;
fn main() {
let text = fs::read_to_string("tasks.txt")?;
println!("{text}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:47
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let text = fs::read_to_string("tasks.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`

El compilador te dice que el operador ? solo se puede usar en una función que devuelve Result u Option. ? significa “si esto es un error, devuélvelo desde la función actual”. Un main normal devuelve (), así que no hay adónde enviar el error. O le das a main un tipo de retorno como Result<(), Box<dyn Error>>, o haces lo mismo que nuestro programa: pones el trabajo en una función run() que devuelve Result y manejas el error una sola vez en main.

Tratar un archivo que no existe como un fallo. Si load_tasks hiciera solo fs::read_to_string(path)?, el programa fallaría en la primera ejecución, antes de que exista ningún archivo. Comprueba si el error es io::ErrorKind::NotFound y, en ese caso, empieza con una lista vacía.

Usar la longitud de la lista como siguiente id. Con las tareas 1, 2 y 3, si eliminas la tarea 1 quedan dos tareas. len() + 1 volvería a dar 3, y ahora dos tareas tendrían el mismo id. Usa el id más grande más uno.

  • Deshacer “hecha”. Agrega un comando undone <id> que vuelva a poner done en false.
  • Editar una tarea. edit 2 Llamar a la abuela esta noche reemplaza el texto de la tarea 2.
  • Borrar las tareas terminadas. clear elimina todas las tareas hechas. Échale un vistazo a Vec::retain (en inglés).
  • Avisar de las líneas rotas. En lugar de saltar las líneas incorrectas en silencio, muestra un aviso con el número de línea.
  • Un comando help que muestre todos los comandos y cómo usarlos.
  • Escribe tests. parse_command y run_command no tocan archivos, así que son fáciles de probar.
  • Usa un analizador de argumentos de verdad. El crate clap (en inglés), que se menciona en la página de Argumentos de línea de comandos, te da --help y subcomandos gratis.
  • Un struct Task guarda los datos. to_line y from_line lo convierten en texto y de vuelta.
  • Un enum Command describe lo que pidió el usuario. parse_command convierte los argumentos en un Command, o en un Err con un mensaje útil.
  • Un Vec<Task> guarda la lista. Funciones pequeñas agregan, buscan, cambian y eliminan tareas.
  • fs::read_to_string y fs::write devuelven Result, y ? pasa los errores hacia arriba. Si el archivo no existe en la primera ejecución, se trata como “no hay tareas”.
  • Mantener la lógica (run_command) separada de los archivos y los argumentos hace que sea fácil de probar y de ejecutar en el Playground.

Comprueba lo aprendido

1. ¿Por qué from_line usa splitn(3, '|') en lugar de split('|')?

2. ¿Qué hace load_tasks cuando tasks.txt todavía no existe?

3. ¿Por qué no puedes usar ? directamente dentro de un fn main() sin tipo de retorno?

4. ¿Qué crate es la mejora habitual para guardar structs como JSON?

Puntuación: 0 / 4