← Оглавление

4.3 Возвращение

Написать - половина дела. Вторая половина называется прочитать. Мастер, умеющий записывать заготовки в журнал, но не умеющий их оттуда брать, ведёт очень дисциплинированную опись того, что потерял навсегда. След есть. Назад по нему пока не вернуться.

Что нужно

save записывает задачи в файл. load должна их оттуда брать. Без неё каждый запуск начинается с пустого хранилища - что бы ни оказалось в tasks.json.

Первый запуск - особый случай: файла ещё нет. Отсутствие файла не ошибка, а просто начало. load обязана это учитывать.

Сборка

load зеркальна save. В src/persistence/mod.rs:

// src/persistence/mod.rs - CHANGED
pub fn load(path: &Path) -> TqResult<Vec<Task>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let content = fs::read_to_string(path)
        .map_err(|e| TqError::Parse(e.to_string()))?;
    let tasks: Vec<Task> = serde_json::from_str(&content)
        .map_err(|e| TqError::Parse(e.to_string()))?;
    Ok(tasks)
}

Загрузить задачи в хранилище через обычный add не получится - add выдаёт каждой задаче новый идентификатор, затирая тот, что был сохранён. new получает новую сигнатуру: принимает уже готовый список задач. В src/store/mod.rs:

// src/store/mod.rs - CHANGED
impl TaskStore {
    pub fn new(tasks: Vec<Task>) -> Self {   // CHANGED: принимает задачи напрямую
        let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
        TaskStore { items: tasks, next_id }
    }
}

unwrap_or(0) + 1 даёт next_id = 1 для пустого списка - при первом запуске хранилище начинается с единицы. Для загруженных задач - сдвиг выше максимума: новые идентификаторы не столкнутся со старыми.

В src/main.rs - загрузить при старте, сохранить при выходе:

// src/main.rs - CHANGED
fn main() -> TqResult<()> {
    let path = Path::new("tasks.json");

    let tasks = persistence::load(path)?;    // NEW
    let mut store = TaskStore::new(tasks);   // CHANGED: new(tasks)

    println!("Loaded: {} task(s)", store.all().len());  // NEW

    store.add("Buy coffee")?;
    store.add("Buy milk")?;
    store.add("Buy eggs")?;

    for task in store.all() {
        println!("{}", task);
    }

    match store.get(1) {
        Ok(task) => println!("found: {}", task),
        Err(e) => println!("{}", e),
    }

    match store.get(99) {
        Ok(task) => println!("{}", task),
        Err(e) => println!("{}", e),
    }

    store.get_mut(1)?.complete();
    println!("done: {}", store.get(1)?.is_done());

    match Task::new(0, "") {
        Ok(task) => store.add(task)?,
        Err(e) => println!("rejected: {}", e),
    }

    persistence::save(store.all(), path)?;   // CHANGED: path вместо Path::new(...)

    Ok(())
}

Результат

Для чистоты эксперимента вначале удалим ранее созданый tasks.json

rm tasks.json 

Тогда первый запуск нам даст:

Loaded: 0 task(s)
#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

а второй запуск:

Loaded: 3 task(s)
#1: Buy coffee [Done]
#2: Buy milk [Todo]
#3: Buy eggs [Todo]
#4: Buy coffee [Todo]
#5: Buy milk [Todo]
#6: Buy eggs [Todo]
found: #1: Buy coffee [Done]
task 99 not found
done: true
rejected: task title cannot be empty

Задачи из первого запуска живы. Процесс завершился - они остались.

Ключевой тест - полный круг: сохранить и загрузить обратно. В src/persistence/tests.rs:

// src/persistence/tests.rs - CHANGED
#[test]
fn save_and_load_roundtrip() {
    let path = std::env::temp_dir().join("tq_test_m43_roundtrip.json");
    let tasks = vec![Task::new(1, "Buy coffee").unwrap()];
    save(&tasks, &path).unwrap();
    let loaded = load(&path).unwrap();
    assert_eq!(loaded.len(), 1);
    assert_eq!(loaded[0].title, "Buy coffee");
    let _ = fs::remove_file(&path);
}

Тесты для отсутствующего файла и пустого списка - в tq/.

make ci проходит. Задачи переживают перезапуск.

Полный код tq для этой главы - 4-memory/03-the-return/.


Lore: path.exists() и гонка состояний

path.exists() - проверка, после которой файл теоретически могут удалить до fs::read_to_string. На практике это не проблема: tq однопоточный, файл меняет только он сам. Но есть и другой подход - открывать файл и обрабатывать отсутствие явно:

use std::io::ErrorKind;

pub fn load(path: &Path) -> TqResult<Vec<Task>> {
    match fs::read_to_string(path) {
        Ok(content) => serde_json::from_str(&content)
            .map_err(|e| TqError::Parse(e.to_string())),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
        Err(e) => Err(TqError::Parse(e.to_string())),
    }
}

e.kind() == ErrorKind::NotFound - фильтр прямо в ветке match. Отсутствие файла идёт в Ok(Vec::new()), любая другая io-ошибка - в Err. В tq оба варианта равнозначны.