← Table of contents

4.1 The Language of Machines

A good craftsman keeps records. A poor craftsman relies on memory - and that works fine right up until three orders have gone missing and need explaining. Memory is convenient: it is always at hand and always, as a rule, already slightly different. Records are inconvenient: they must be kept. But only one of the two survives a closed door.

What You Need

tq can add tasks, complete them, and find them by identifier. Each time - from nothing. Everything added in the previous run disappeared with it. tq has no records.

To save tasks to a file, they must be translated into a text format. JSON fits: it is readable by people, readable by machines, and most tools understand it. The translation tool - serde - is already in place. What remains is to tell Task how to use it.

The Vocabulary

In src/task/mod.rs, add the import and attributes to Status and Task:

// src/task/mod.rs - CHANGED: serde import; derives on Status and Task
use crate::error::{TqError, TqResult};
use serde::{Deserialize, Serialize};
use std::fmt;

#[derive(Debug, Serialize, Deserialize)]
pub enum Status {
    Todo,
    Done,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub status: Status,
}

#[derive(Serialize, Deserialize)] is needed on Status as well as Task: serde knows how to handle standard types - numbers, strings - but Status is our own type, and without explicit instruction it cannot serialize it.

Add one line in src/main.rs to confirm the translation works:

// src/main.rs - add before Ok(())
let json = serde_json::to_string_pretty(store.get(1)?).unwrap();
println!("as JSON:\n{}", json);

The .unwrap() here is temporary: the serde_json error type is not yet connected to TqResult.

The Result

#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
as JSON:
{
  "id": 1,
  "title": "Buy coffee",
  "status": "Done"
}

Two new tests in src/task/tests.rs - they check the translation in both directions:

// src/task/tests.rs - add at the end
#[test]
fn task_serializes_to_json() {
    let task = Task::new(1, "Buy coffee").unwrap();
    let json = serde_json::to_string(&task).unwrap();
    assert!(json.contains(r#""title":"Buy coffee""#));
    assert!(json.contains(r#""status":"Todo""#));
}

#[test]
fn task_roundtrips_through_json() {
    let task = Task::new(1, "Buy coffee").unwrap();
    let json = serde_json::to_string(&task).unwrap();
    let restored: Task = serde_json::from_str(&json).unwrap();
    assert_eq!(restored.id, task.id);
    assert_eq!(restored.title, task.title);
    assert!(!restored.is_done());
}

make ci passes. Writing to disk is the next step.

The complete tq code for this chapter is in 4-memory/01-the-language-of-machines/.


Lore: Derive Macros

#[derive(Debug)] and #[derive(Clone)] are built into the compiler - no crate needed. #[derive(Serialize, Deserialize)] is a different case: this is a procedural macro shipped by the serde crate itself. That is exactly what the --features derive flag from the previous chapter enables.

At compile time, it reads the type definition and generates an implementation of the Serialize trait - the same one you could have written by hand. This is not magic - it is a tool that writes code for you.

Why a separate feature? Compiling procedural macros adds build time. A crate that uses serde only as a set of traits - in a library, not a binary - can do without code generation. The flag means you do not pay for what you do not use.