← Table of contents

4.4 Dates a Human Can Read

In a good ledger, every entry is marked with a date. Not because it is customary - because an entry without a date is not an entry but a draft with ambitions. It could have been yesterday, it could have been three months ago, and now there is no way to tell. A task survives between runs: it can be found, read, completed. When it was created - that is a question with no answer. The difference between “recorded” and “recorded on such-and-such date” is not noticed immediately. Always at exactly the worst moment.

The Attempt

For working with time in Rust there is chrono. cargo add chrono adds it to the dependencies, and a field can be added to Task.

In Cargo.toml:

chrono = "0.4"

In src/task/mod.rs:

// src/task/mod.rs - attempt
use chrono::{DateTime, Utc};

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

cargo build:

error[E0277]: the trait bound `DateTime<Utc>: Serialize` is not satisfied
  --> src/task/mod.rs:XX:XX
   |
XX | #[derive(Debug, Serialize, Deserialize)]
   |                 ^^^^^^^^^ the trait `Serialize` is not implemented for `DateTime<Utc>`
   |
   = note: required for `Task` to implement `Serialize`

DateTime<Utc> does not know how to become JSON.

What the Compiler Knows

The same mechanism that gave serde its derive macros in chapter 4.0: chrono supports serde, but keeps that support behind a feature flag. Not everyone who uses chrono for date calculations needs serde - pulling in extra dependencies for a project that does not need them is poor form.

chrono provides a "serde" feature - it adds Serialize and Deserialize implementations for all chrono types, including DateTime<Utc>. Update Cargo.toml:

chrono = { version = "0.4", features = ["serde"] }

The Enchantment

With the right flag the compiler is satisfied. But if there is already a tasks.json without a created_at field, the next run will produce a missing field error. Add an attribute to Task:

// src/task/mod.rs - CHANGED
#[derive(Debug, Serialize, Deserialize)]
pub struct Task {
    pub id: u64,
    pub title: String,
    pub status: Status,
    #[serde(default = "Utc::now")]
    pub created_at: DateTime<Utc>,
}

#[serde(default = "Utc::now")] tells serde: if the field is absent in the JSON, call Utc::now() and use the result. This is the backwards-compatibility solution: a tasks.json from the previous chapter contains no created_at, and without this attribute loading would fail with missing field. Old tasks will receive the current time as their creation date - imprecise, but the file will load.

Update Display as well - so the date is visible in the terminal, not only in JSON:

// src/task/mod.rs - CHANGED
impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "#{}: {} [{:?}] ({})",
            self.id,
            self.title,
            self.status,
            self.created_at.format("%Y-%m-%d")
        )
    }
}

.format("%Y-%m-%d") is a chrono method for formatting a date. Year, month, day - that is enough here. Other formats can be explored in the chrono documentation: %d.%m.%Y gives 30.05.2026, %b %d gives May 30.

Both places where a task is created need updating - the logic does not change, only the new field is added:

impl Task {
    pub fn new(id: u64, title: &str) -> TqResult<Task> {
        if title.is_empty() {
            return Err(TqError::EmptyTitle);
        }
        Ok(Task {
            id,
            title: String::from(title),
            status: Status::Todo,
            created_at: Utc::now(),   // NEW
        })
    }
}

impl From<&str> for Task {
    fn from(title: &str) -> Self {
        Task {
            id: 0,
            title: title.to_string(),
            status: Status::Todo,
            created_at: Utc::now(),   // NEW
        }
    }
}

Utc::now() returns the current moment as DateTime<Utc>. Both constructors receive the timestamp the same way.

Now tasks.json carries the creation date of each task:

[
  {
    "id": 1,
    "title": "Buy coffee",
    "status": "Done",
    "created_at": "2026-05-30T09:41:05.318Z"
  }
]

The format is RFC 3339, a subset of ISO 8601: year, month, day, T, time, Z for UTC. Readable without a decoder.

In the terminal each task now looks like this:

Loaded: 0 task(s)
#1: Buy coffee [Todo] (2026-05-30)
#2: Buy milk [Todo] (2026-05-30)
#3: Buy eggs [Todo] (2026-05-30)

The most important thing to verify is that created_at survives a JSON roundtrip. Add use chrono::Utc; to src/task/tests.rs and three tests:

// src/task/tests.rs - CHANGED
#[test]
fn new_task_records_creation_time() {
    let before = Utc::now();
    let task = Task::new(1, "Buy coffee").unwrap();
    let after = Utc::now();
    assert!(task.created_at >= before);
    assert!(task.created_at <= after);
}

#[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_eq!(restored.created_at, task.created_at);   // NEW
    assert!(!restored.is_done());
}

#[test]
fn task_loads_without_created_at() {
    let json = r#"{"id":1,"title":"Buy coffee","status":"Todo"}"#;
    let task: Task = serde_json::from_str(json).unwrap();
    assert_eq!(task.title, "Buy coffee");
}

make ci passes. The full test suite is in tq/.

The complete tq code for this chapter is in 4-memory/04-dates-a-human-can-read/.


Lore: How to Find a Crate’s Feature Flags

Feature flags are normal practice for crates on crates.io, not an exception. Whenever a crate provides a capability that not everyone needs, it goes behind a flag. serde and chrono are not special cases - they are ordinary examples of this convention.

To find out what feature flags a crate has and what each enables, check docs.rs: every crate’s page lists features with descriptions. To see which features are enabled in the current build: cargo tree -e features.