← Índice

4.3 El regreso

Esta traducción fue asistida por IA y revisada superficialmente. Si encuentras errores o algo no suena natural, escríbeme — lo agradezco.

Escribir es la mitad del trabajo. La segunda mitad se llama leer. Un artesano que sabe registrar piezas en un libro de cuentas pero no sabe recuperarlas de él lleva un inventario muy disciplinado de lo que ha perdido para siempre. El rastro existe. Todavía no hay un camino de vuelta por él.

Lo que necesitas

save escribe las tareas en un archivo. load debe recuperarlas. Sin ello, cada ejecución comienza con un almacén vacío - sea lo que sea lo que haya en tasks.json.

La primera ejecución es un caso especial: el archivo todavía no existe. La ausencia de un archivo no es un error - es simplemente un comienzo. load debe tenerlo en cuenta.

La construcción

load es el espejo de save. En src/persistence/mod.rs:

// src/persistence/mod.rs - CAMBIADO
pub fn load(path: &Path) -> TqResult<Vec<Task>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let content = fs::read_to_string(path)
        .map_err(|e| TqError::Parse(e.to_string()))?;
    let tasks: Vec<Task> = serde_json::from_str(&content)
        .map_err(|e| TqError::Parse(e.to_string()))?;
    Ok(tasks)
}

Cargar tareas al almacén a través del habitual add no funcionará - add asigna a cada tarea un nuevo identificador, sobreescribiendo el que se guardó. new recibe una nueva firma: ahora acepta una lista de tareas ya construida. En src/store/mod.rs:

// src/store/mod.rs - CAMBIADO
impl TaskStore {
    pub fn new(tasks: Vec<Task>) -> Self {   // CAMBIADO: acepta tareas directamente
        let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
        TaskStore { items: tasks, next_id }
    }
}

unwrap_or(0) + 1 da next_id = 1 para una lista vacía - en la primera ejecución el almacén empieza desde uno. Para tareas cargadas se desplaza por encima del máximo: los nuevos identificadores no colisionarán con los antiguos.

En src/main.rs - cargar al inicio, guardar al salir:

// src/main.rs - CAMBIADO
fn main() -> TqResult<()> {
    let path = Path::new("tasks.json");

    let tasks = persistence::load(path)?;    // NUEVO
    let mut store = TaskStore::new(tasks);   // CAMBIADO: new(tasks)

    println!("Loaded: {} task(s)", store.all().len());  // NUEVO

    store.add("Buy coffee")?;
    store.add("Buy milk")?;
    store.add("Buy eggs")?;

    for task in store.all() {
        println!("{}", task);
    }

    match store.get(1) {
        Ok(task) => println!("found: {}", task),
        Err(e) => println!("{}", e),
    }

    match store.get(99) {
        Ok(task) => println!("{}", task),
        Err(e) => println!("{}", e),
    }

    store.get_mut(1)?.complete();
    println!("done: {}", store.get(1)?.is_done());

    match Task::new(0, "") {
        Ok(task) => store.add(task)?,
        Err(e) => println!("rejected: {}", e),
    }

    persistence::save(store.all(), path)?;   // CAMBIADO: path en lugar de Path::new(...)

    Ok(())
}

El resultado

Para empezar limpio, elimina el tasks.json creado antes:

rm tasks.json

La primera ejecución produce:

Loaded: 0 task(s)
#1: Buy coffee [Todo]
#2: Buy milk [Todo]
#3: Buy eggs [Todo]
found: #1: Buy coffee [Todo]
task 99 not found
done: true
rejected: task title cannot be empty

La segunda ejecución:

Loaded: 3 task(s)
#1: Buy coffee [Done]
#2: Buy milk [Todo]
#3: Buy eggs [Todo]
#4: Buy coffee [Todo]
#5: Buy milk [Todo]
#6: Buy eggs [Todo]
found: #1: Buy coffee [Done]
task 99 not found
done: true
rejected: task title cannot be empty

Las tareas de la primera ejecución están vivas. El proceso terminó - ellas permanecieron.

El test clave es el círculo completo: guardar y cargar de vuelta. En src/persistence/tests.rs:

// src/persistence/tests.rs - CAMBIADO
#[test]
fn save_and_load_roundtrip() {
    let path = std::env::temp_dir().join("tq_test_m43_roundtrip.json");
    let tasks = vec![Task::new(1, "Buy coffee").unwrap()];
    save(&tasks, &path).unwrap();
    let loaded = load(&path).unwrap();
    assert_eq!(loaded.len(), 1);
    assert_eq!(loaded[0].title, "Buy coffee");
    let _ = fs::remove_file(&path);
}

Los tests para un archivo ausente y una lista vacía están en tq/.

make ci pasa. Las tareas sobreviven a un reinicio.

El código completo de tq para este capítulo está en 4-memory/03-the-return/.


Lore: path.exists() y una condición de carrera

path.exists() es una comprobación tras la cual el archivo podría teóricamente eliminarse antes de fs::read_to_string. En la práctica esto no es un problema: tq es monohilo y solo él modifica el archivo. Pero hay otro enfoque - abrir el archivo y manejar su ausencia explícitamente:

use std::io::ErrorKind;

pub fn load(path: &Path) -> TqResult<Vec<Task>> {
    match fs::read_to_string(path) {
        Ok(content) => serde_json::from_str(&content)
            .map_err(|e| TqError::Parse(e.to_string())),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
        Err(e) => Err(TqError::Parse(e.to_string())),
    }
}

e.kind() == ErrorKind::NotFound es un filtro directamente en el brazo del match. Un archivo ausente va a Ok(Vec::new()); cualquier otro error de E/S va a Err. En tq, ambos enfoques son equivalentes.