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() возвращает контроль: теперь мы решаем, что написать и куда.