← Оглавление

4.5 Собственные инструкции

В порядочном цехе у верстака есть листок с настройками: где хранить работу, при каком режиме, что проверить первым. Мастер, уходя, не забирает его с собой - листок нужен инструменту, а не мастеру. Инструмент без листка тоже работает. Он делает всё так, как делал всегда, независимо от того, что изменилось снаружи. Это принято называть надёжностью. У этого есть и другое название.

Что нужно

Путь к файлу с задачами сейчас зашит прямо в код. Это работает - ровно до тех пор, пока задачи нужно хранить именно там. Нужна структура Config, которая хранит настройки, и файл config.toml, откуда эти настройки берутся. Если файла нет - работают умолчания.

Сборка

toml - крейт для чтения файлов в одноимённом формате. cargo add toml добавляет его в зависимости. В Cargo.toml:

# Cargo.toml - CHANGED
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"

Config - новый модуль. В src/config/mod.rs:

// src/config/mod.rs - NEW
use crate::error::{TqError, TqResult};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Deserialize)]
pub struct Config {
    pub data_dir: PathBuf,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            data_dir: PathBuf::from("."),
        }
    }
}

impl Config {
    pub fn load(path: &Path) -> TqResult<Config> {
        let config = if !path.exists() {
            Config::default()
        } else {
            let content = fs::read_to_string(path).map_err(|e| TqError::Parse(e.to_string()))?;
            toml::from_str(&content).map_err(|e| TqError::Parse(e.to_string()))?
        };
        if !config.data_dir.exists() {
            return Err(TqError::Parse(format!(
                "data_dir '{}' does not exist; create it with: mkdir -p {}",
                config.data_dir.display(),
                config.data_dir.display()
            )));
        }
        Ok(config)
    }
}

#[derive(Deserialize)] - тот же механизм, что и для Task, только без Serialize: конфигурацию читают, не пишут. toml::from_str читает содержимое файла и заполняет структуру. Если файл отсутствует - Config::default() даёт умолчания.

impl Default - стандартный трейт Rust для значений по умолчанию. Он позволяет написать Config::default() там, где нужен конфиг “из воздуха”, и объявляет, что это значит явно, а не через магические константы где-то в коде.

Сам файл config.toml - в корне проекта, рядом с Cargo.toml. cargo run запускается оттуда же, поэтому Path::new("config.toml") находит его правильно. Одна строка:

data_dir = "."

В src/main.rs путь теперь берётся из конфигурации. Добавить mod config и use config::Config, заменить две строки в начале main:

// src/main.rs - CHANGED
fn main() -> TqResult<()> {
    let config = Config::load(Path::new("config.toml"))?;
    let path = config.data_dir.join("tasks.json");   // CHANGED

    let tasks = persistence::load(&path)?;
    // ...
    persistence::save(store.all(), &path)?;

Одна строка с захардкоженным путём стала двумя: загрузить конфиг, собрать путь из него. До этого переменная path была &Path - Path::new возвращает ссылку сразу. Теперь join возвращает новый PathBuf, а не ссылку - поэтому в вызовах появился &path. Остальной main не меняется.

Результат

Без config.toml - поведение прежнее, tq ищет tasks.json в текущем каталоге. С файлом:

data_dir = "/home/user/.tq"

задачи хранятся там - при условии что директория существует. Если нет:

Error: Parse("data_dir '/home/user/.tq' does not exist; create it with: mkdir -p /home/user/.tq")

load проверяет data_dir сразу после разбора конфига - тот же паттерн if !path.exists(), что уже есть в persistence::load. Ошибка не просто сообщает о проблеме, но и говорит что делать. Код не меняется. Только листок на верстаке.

Четыре теста фиксируют все пути. В src/config/tests.rs:

// src/config/tests.rs - NEW
use super::*;

#[test]
fn config_defaults_to_current_dir() {
    let config = Config::default();
    assert_eq!(config.data_dir, PathBuf::from("."));
}

#[test]
fn config_load_missing_file_returns_default() {
    let path = std::env::temp_dir().join("tq_test_m45_no_config.toml");
    let _ = std::fs::remove_file(&path);
    let config = Config::load(&path).unwrap();
    assert_eq!(config.data_dir, PathBuf::from("."));
}

#[test]
fn config_load_parses_data_dir() {
    let path = std::env::temp_dir().join("tq_test_m45_config.toml");
    std::fs::write(&path, r#"data_dir = "/tmp""#).unwrap();
    let config = Config::load(&path).unwrap();
    assert_eq!(config.data_dir, PathBuf::from("/tmp"));
    let _ = std::fs::remove_file(&path);
}

#[test]
fn config_load_rejects_missing_data_dir() {
    let config_path = std::env::temp_dir().join("tq_test_m45_bad_config.toml");
    let missing = "/tmp/tq_nonexistent_xyz";
    let _ = std::fs::remove_dir_all(missing);
    std::fs::write(&config_path, format!(r#"data_dir = "{}""#, missing)).unwrap();
    let result = Config::load(&config_path);
    assert!(result.is_err());
    let _ = std::fs::remove_file(&config_path);
}

make ci проходит.

Полный код tq для этой главы - 4-memory/05-reading-its-own-instructions/.


Lore: use - псевдоним, не замок

В M4.1 в коде появилась строка use serde::{Deserialize, Serialize} - без объяснений, как будто иначе нельзя. На самом деле use не открывает доступ к крейту: любой крейт из Cargo.toml доступен по полному пути в любом месте кода без единого use.

use - это псевдоним. Он позволяет писать короткое имя вместо полного пути. В M4.1 псевдоним был нужен: #[derive(Deserialize)] - короткое имя в атрибуте, без use пришлось бы писать #[derive(serde::Deserialize)]. В этой главе toml::from_str - полный путь, используется один раз, сама запись проста - псевдоним был бы избыточен.

Та же логика у use std::fmt и use std::path::Path - просто удобство, не обязанность.


Lore: TOML как формат для конфигурации

JSON удобен для обмена данными между программами. Для файлов, которые редактирует человек, у него есть неудобная особенность: комментарии запрещены. Написать # путь к данным рядом со значением нельзя - это синтаксическая ошибка.

TOML проектировался именно для конфигурационных файлов. Комментарии - нативно:

# Where tasks are stored
data_dir = "."

Иерархия через секции, даты как отдельный тип, многострочные строки. Человек открывает файл и понимает его без документации. Именно поэтому большинство инструментов в экосистеме Rust - сам Cargo в том числе - используют TOML для конфигурации.