← Table of contents

4.2 The First Trace

People discovered fairly quickly that memory is unreliable. That discovery stopped no one from relying on it - but it did produce writing, which is itself a remarkable result. The difference between a spoken word and a written one is simple: one disappears, the other stays. Sometimes for too long. The artifact can translate tasks into text. The text lives in the process’s memory and ends there. That is not a record. That is a draft no one reads.

The Attempt

The task is clear: take the list of tasks, turn them into JSON, write to a file. Create src/persistence/mod.rs and declare the module in src/main.rs with mod persistence;. The save logic looks straightforward:

// src/persistence/mod.rs
use crate::error::TqResult;
use crate::task::Task;
use std::fs;
use std::path::Path;

pub fn save(tasks: &[Task], path: &Path) -> TqResult<()> {
    let json = serde_json::to_string_pretty(tasks)?;
    fs::write(path, json)?;
    Ok(())
}

cargo build returns two errors:

error[E0277]: `?` couldn't convert the error to `TqError`
  --> src/persistence/mod.rs:XX:XX
   |
XX |     let json = serde_json::to_string_pretty(tasks)?;
   |                                                   ^ the trait `From<serde_json::Error>`
   |                                                     is not implemented for `TqError`

error[E0277]: `?` couldn't convert the error to `TqError`
  --> src/persistence/mod.rs:XX:XX
   |
XX |     fs::write(path, json)?;
   |                          ^ the trait `From<std::io::Error>`
   |                            is not implemented for `TqError`

What the Compiler Knows

The ? operator tries to convert the error into the type the function expects - namely TqError. That requires an implementation of From<E> for TqError, where E is the error type inside. Neither serde_json::Error nor io::Error has one. Both ? operators fail for the same reason.

Adding From is not required. It is enough to make the conversion explicit before ? has a chance to demand it.

The Enchantment

First - src/error/mod.rs. One new variant is needed:

// src/error/mod.rs - CHANGED
use std::fmt;

#[derive(Debug)]
pub enum TqError {
    EmptyTitle,
    NotFound(u64),
    Parse(String),                      // NEW
}

pub type TqResult<T> = Result<T, TqError>;

impl fmt::Display for TqError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            TqError::EmptyTitle => write!(f, "task title cannot be empty"),
            TqError::NotFound(id) => write!(f, "task {} not found", id),
            TqError::Parse(msg) => write!(f, "parse error: {}", msg),   // NEW
        }
    }
}

The new variant exists - now the errors have somewhere to go. In src/persistence/mod.rs, both lines convert the error into a string and place it in TqError::Parse:

// src/persistence/mod.rs - CHANGED
use crate::error::{TqError, TqResult};
use crate::task::Task;
use std::fs;
use std::path::Path;

pub fn save(tasks: &[Task], path: &Path) -> TqResult<()> {
    let json = serde_json::to_string_pretty(tasks).map_err(|e| TqError::Parse(e.to_string()))?;
    fs::write(path, json).map_err(|e| TqError::Parse(e.to_string()))?;
    Ok(())
}

#[cfg(test)]
mod tests;

.map_err takes a closure and applies it to the error if one exists. ? after it behaves as usual - only the type is now TqError, and no From is required.

In src/main.rs - add use std::path::Path; and the save call before Ok(()):

// src/main.rs - add before Ok(())
persistence::save(store.all(), Path::new("tasks.json"))?;

cargo run completes without errors. A tasks.json file appears next to the binary:

[
  {
    "id": 1,
    "title": "Buy coffee",
    "status": "Done"
  },
  {
    "id": 2,
    "title": "Buy milk",
    "status": "Todo"
  },
  {
    "id": 3,
    "title": "Buy eggs",
    "status": "Todo"
  }
]

Two tests in src/persistence/tests.rs - one checks the file contents, the other that an empty list is written correctly:

// src/persistence/tests.rs - NEW
use super::*;
use crate::task::Task;
use std::fs;

#[test]
fn save_writes_tasks_as_json() {
    let path = std::env::temp_dir().join("tq_test_m42_save.json");
    let tasks = vec![Task::new(1, "Buy coffee").unwrap()];
    save(&tasks, &path).unwrap();
    let content = fs::read_to_string(&path).unwrap();
    assert!(content.contains(r#""title": "Buy coffee""#));
    assert!(content.contains(r#""status": "Todo""#));
    let _ = fs::remove_file(&path);
}

#[test]
fn save_writes_empty_array_for_no_tasks() {
    let path = std::env::temp_dir().join("tq_test_m42_empty.json");
    save(&[], &path).unwrap();
    let content = fs::read_to_string(&path).unwrap();
    assert_eq!(content.trim(), "[]");
    let _ = fs::remove_file(&path);
}

make ci passes. tq now leaves a first trace.

The complete tq code for this chapter is in 4-memory/02-the-first-trace/.


Lore: How .map_err Works

serde_json::to_string_pretty(tasks)
    .map_err(|e| TqError::Parse(e.to_string()))?

|e| is the closure argument: the error returned by serde_json. Ok values pass through unchanged. The manual expansion looks like this:

match serde_json::to_string_pretty(tasks) {
    Ok(v) => v,
    Err(e) => return Err(TqError::Parse(e.to_string())),
}

.map_err does not change what happens - only makes the notation shorter. Closures will appear often; the ability to expand them by hand is a useful reflex.