← Оглавление

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.