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
tqcode for this chapter is in4-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.