4.4 Даты, которые читает человек
В хорошем журнале каждая запись помечена датой. Не потому что так принято - потому что запись без даты не запись, а черновик с претензией. Можно было вчера, можно - три месяца назад, а разобраться теперь не получится. Задача выживает между запусками: её можно найти, прочитать, завершить. Когда она появилась - вопрос без ответа. Разница между “записано” и “записано тогда-то” обнаруживается не сразу. Всегда именно тогда, когда это хуже всего.
Попытка
Для работы со временем в Rust есть chrono. cargo add chrono добавляет его в
зависимости, и к Task можно добавить поле.
В Cargo.toml:
chrono = "0.4"
В 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> не умеет превращаться в JSON.
Что знает компилятор
Та же механика, что дала serde его derive-макросы в главе 4.0: chrono поддерживает
serde, но держит эту поддержку за feature-флагом. Не всем, кто использует chrono для
работы с датами, нужен serde - тянуть лишние зависимости в проект, которому они не
нужны, плохой тон.
chrono предоставляет feature "serde" - он добавляет реализации Serialize и
Deserialize для всех типов chrono, включая DateTime<Utc>. Обновим Cargo.toml:
chrono = { version = "0.4", features = ["serde"] }
Заклинание
С нужным флагом компилятор доволен. Но если уже есть tasks.json без поля created_at -
следующий запуск даст missing field. Добавим в 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")] говорит serde: если поле отсутствует в JSON - вызови
Utc::now() и используй результат. Это и есть решение для обратной совместимости: tasks.json
из предыдущей главы не содержит created_at, и без этого атрибута загрузка падала бы с
missing field. Старые задачи получат текущее время как дату создания - неточно, но файл загрузится.
Обновим и Display - чтобы дата была видна не только в 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") - метод chrono для форматирования даты. Год, месяц, день -
нам этого достаточно, поиграть с форматами можно в
документации chrono:
%d.%m.%Y даст 30.05.2026, %b %d - May 30.
Оба места, где создаётся задача, нужно обновить - там ничего не меняется в логике, просто добавляется поле:
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() возвращает текущий момент как DateTime<Utc>. Оба конструктора получают
метку времени одинаково.
Теперь tasks.json несёт дату каждой задачи:
[
{
"id": 1,
"title": "Buy coffee",
"status": "Done",
"created_at": "2026-05-30T09:41:05.318Z"
}
]
Формат - RFC 3339, подмножество ISO 8601: год, месяц, день, T, время, Z - UTC.
Читается без расшифровки.
В терминале каждая задача теперь выглядит так:
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)
Главное, что нужно проверить - created_at выживает в roundtrip через JSON. В
src/task/tests.rs добавить use chrono::Utc; и три теста:
// 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 проходит. Полный набор тестов - в tq/.
Полный код
tqдля этой главы -4-memory/04-dates-a-human-can-read/.
Lore: Как узнать о feature-флагах крейта
Feature-флаги - нормальная практика для крейтов на crates.io, а не исключение. Каждый
раз, когда крейт предоставляет возможность, которая нужна не всем, она выносится за флаг.
serde и chrono - не особые случаи, а обычный пример этого соглашения.
Узнать, какие feature-флаги есть у крейта и что каждый включает, можно на docs.rs: страница
каждого крейта перечисляет features с описанием. Посмотреть, какие features включены в
текущей сборке - cargo tree -e features.