← Оглавление

5.2 Четыре команды

Мастер, умеющий объяснять отказ так же ясно, как умеет описывать успех, встречается реже, чем хотелось бы. Причина понятна: отказы случаются реже успехов, и над ними думают меньше. Это рассуждение всегда кажется разумным - ровно до того момента, когда что-то идёт не так.

Что нужно

tq отвечает на ошибочные команды в отладочном формате: то, как данные выглядят внутри программы, а не то, что мастерская потрудилась сформулировать для людей. Код завершения процесса нигде не выставляется явно. Команда add подтверждает факт одним словом, не называя результат.

Сборка

Конкретно: cargo run -- add "" сейчас завершается так:

Error: EmptyTitle

fn main() -> TqResult<()> опирается на трейт Termination - он выводит ошибку через {:?}, формат Debug. Именно поэтому видно EmptyTitle, а не title cannot be empty, которое TqError умеет давать сам. У нас есть Display но мы этим пока не пользуемся.

Сделаем немного правок в src/main.rs, чтобы взять управление в свои руки:

// src/main.rs - CHANGED
fn main() {
    if let Err(e) = run() {
        eprintln!("tq: {e}");
        std::process::exit(1);
    }
}

fn run() -> TqResult<()> {
    let cli = Cli::parse();
    let config = Config::load(Path::new("config.toml"))?;
    cli::run(cli, config)
}

eprintln! пишет в stderr - поток для диагностики. println! - в stdout, поток для вывода. Разделение существенное: cargo run -- list | grep coffee читает stdout; если что-то пошло не так, ошибка в такой цепочке должна проходить мимо, в stderr.

{e} вызывает Display - тот самый, который возвращает title cannot be empty.

process::exit(1) завершает процесс с кодом 1. Код 0 означает успех, ненулевой - сбой. Это договорённость, на которой строится взаимодействие команд в оболочке.

run() - вспомогательная функция, в которой живёт привычный ?. main теперь только проверяет результат.


Команда add сейчас выводит только одно слово. Добавим полезной информации для пользователя.

Сначала научим Store::add возвращать ID добавленной задачи. В src/store/mod.rs:

// src/store/mod.rs - CHANGED
pub trait Store<T: HasId> {
    fn add(&mut self, item: impl Into<T>) -> TqResult<u64>;
    // ...
}

impl Store<Task> for TaskStore {
    fn add(&mut self, item: impl Into<Task>) -> TqResult<u64> {
        let mut task = item.into();
        let id = self.next_id;
        task.id = id;
        self.next_id += 1;
        self.items.push(task);
        Ok(id)
    }
    // ...
}

Теперь в src/cli/mod.rs:

// src/cli/mod.rs - CHANGED
Commands::Add { title } => {
    let task = Task::new(0, &title)?;
    let id = store.add(task)?;
    persistence::save(store.all(), &path)?;
    println!("{}", store.get(id)?);
}

store.add() возвращает ID добавленной задачи - его сразу передаём в store.get().

Команда done выводит то же самое одно слово. Попробуй исправить её самостоятельно - store.get(id)? уже знакомо. Готовое решение есть в companion.

Результат

$ cargo run -- add "Buy coffee" && echo "success"
#1: Buy coffee [Todo] (2026-06-03)
success

&& продолжает цепочку только при коде 0.

$ cargo run -- add "" && echo "success"
tq: title cannot be empty

Ошибки идут в stderr, человекочитаемым текстом. Успех - в stdout, с деталями. make ci проходит.

Полный код tq для этой главы - 5-interface/02-the-four-commands/.


Lore: почему fn main() → Result печатает Debug

Rust разрешает писать fn main() -> Result<(), E> - удобно, ? работает прямо в main. Но когда main возвращает Err(e), форматирование берёт на себя стандартная библиотека: она выводит Error: {e:?} - именно Debug, не Display. Отсюда EmptyTitle вместо title cannot be empty.

Display мы написали сами. Стандартная библиотека его не спрашивала.

Явная обработка в run() возвращает контроль: теперь мы решаем, что написать и куда.