← Table of contents

4.3 The Return

Writing is half the work. The second half is called reading. A craftsman who knows how to record pieces in a ledger but not how to retrieve them from it is keeping a very disciplined inventory of what they have lost forever. The trace exists. There is no path back along it yet.

What You Need

save writes tasks to a file. load must retrieve them. Without it, every run starts with an empty store - whatever may be in tasks.json.

The first run is a special case: the file does not exist yet. The absence of a file is not an error - it is simply a beginning. load must account for this.

The Build

load mirrors save. In src/persistence/mod.rs:

// src/persistence/mod.rs - CHANGED
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)
}

Loading tasks into the store through the usual add will not work - add assigns each task a new identifier, overwriting the one that was saved. new gets a new signature: it now accepts an already-built list of tasks. In src/store/mod.rs:

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

unwrap_or(0) + 1 gives next_id = 1 for an empty list - on the first run the store starts from one. For loaded tasks it shifts above the maximum: new identifiers will not collide with old ones.

In src/main.rs - load at startup, save on exit:

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

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

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

    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)?;   // CHANGED: path instead of Path::new(...)

    Ok(())
}

The Result

To start clean, delete the tasks.json created earlier:

rm tasks.json

The first run produces:

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

The second run:

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

The tasks from the first run are alive. The process ended - they remained.

The key test is the full circle: save and load back. In src/persistence/tests.rs:

// src/persistence/tests.rs - CHANGED
#[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);
}

Tests for a missing file and an empty list are in tq/.

make ci passes. Tasks survive a restart.

The complete tq code for this chapter is in 4-memory/03-the-return/.


Lore: path.exists() and a Race Condition

path.exists() is a check after which the file could theoretically be deleted before fs::read_to_string. In practice this is not a problem: tq is single-threaded and only it modifies the file. But there is another approach - open the file and handle its absence explicitly:

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 is a filter directly in the match arm. A missing file goes to Ok(Vec::new()); any other I/O error goes to Err. In tq, both approaches are equivalent.