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