← Оглавление

5.1 Голос у двери

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

Что нужно

main из M4.5 запускает одну и ту же последовательность при каждом вызове: загрузить конфиг, добавить три задачи, вывести список, завершить одну. Командная строка не влияет ни на что. Написать tq list или tq add "Buy coffee" - всё равно что говорить в стену.

Нужен разборщик аргументов командной строки. clap - стандартный выбор в Rust-экосистеме: derive-интерфейс позволяет описать CLI как структуру, а --help генерируется из неё автоматически.

Сборка

В Cargo.toml два изменения: [[bin]] добавить вручную, clap - через cargo add clap --features derive:

# Cargo.toml - CHANGED
[[bin]]
name = "tq"
path = "src/main.rs"

[dependencies]
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"

[[bin]] в Cargo.toml называет исполняемый файл tq - именно это имя появится в Usage: в --help и в target/debug/. Без него исполняемый файл берёт имя из поля name в [package] - это подходит, если пакет уже называется tq. В примерах кода из папки tq/ пакет называется по теме главы, поэтому там [[bin]] необходим.

Типы и логика CLI живут в отдельном src/cli/mod.rs. src/main.rs остаётся точкой входа: распарсить аргументы, загрузить конфиг, передать управление.

В src/cli/mod.rs:

// src/cli/mod.rs - NEW
use crate::config::Config;
use crate::error::TqResult;
use crate::persistence;
use crate::store::{Store, TaskStore};
use crate::task::Task;
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(about = "Task queue manager", version)]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Add a new task
    Add {
        /// Task title
        title: String,
    },
    /// List all tasks
    List,
    /// Show a task
    Get {
        /// Task id
        id: u64,
    },
    /// Mark a task done
    Done {
        /// Task id
        id: u64,
    },
}

pub fn run(cli: Cli, config: Config) -> TqResult<()> {
    let path = config.data_dir.join("tasks.json");
    let tasks = persistence::load(&path)?;
    let mut store = TaskStore::new(tasks);

    match cli.command {
        Commands::Add { title } => {
            let task = Task::new(0, &title)?;
            store.add(task)?;
            persistence::save(store.all(), &path)?;
            println!("added");
        }
        Commands::List => {
            for task in store.all() {
                let marker = if task.is_done() { "" } else { "·" };
                println!("{marker} {task}");
            }
        }
        Commands::Get { id } => {
            println!("{}", store.get(id)?);
        }
        Commands::Done { id } => {
            store.get_mut(id)?.complete();
            persistence::save(store.all(), &path)?;
            println!("done");
        }
    }

    Ok(())
}

#[derive(Parser)] просит clap сгенерировать парсер из Cli. #[command(...)] описывает инструмент: about - строка описания; version без аргумента - clap сам возьмёт версию из Cargo.toml. #[command(subcommand)] говорит, что поле command - диспетчер: clap ищет первый позиционный аргумент в вариантах Commands.

run принимает уже разобранный Cli и загруженный Config - само чтение окружения остаётся на стороне main. Add создаёт задачу через Task::new - пустой заголовок вернёт ошибку ещё до записи; 0 как ID - заглушка, store перепишет его своим следующим значением. List помечает выполненные задачи , невыполненные ·.

src/main.rs добавляет mod cli и сводит fn main к трём строкам:

// src/main.rs - CHANGED
mod cli;
mod config;
mod error;
mod persistence;
mod store;
mod task;

use clap::Parser;
use cli::Cli;
use config::Config;
use error::TqResult;
use std::path::Path;

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

parse_from проверяет разбор аргументов без чтения env::args(). В src/cli/tests.rs:

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

#[test]
fn cli_parse_add() {
    let cli = Cli::parse_from(["tq", "add", "Buy coffee"]);
    let Commands::Add { title } = cli.command else { panic!("expected Add") };
    assert_eq!(title, "Buy coffee");
}

#[test]
fn cli_parse_list() {
    let cli = Cli::parse_from(["tq", "list"]);
    assert!(matches!(cli.command, Commands::List));
}

#[test]
fn cli_parse_done() {
    let cli = Cli::parse_from(["tq", "done", "42"]);
    assert!(matches!(cli.command, Commands::Done { id: 42 }));
}

Результат

$ cargo run -- --help
Task queue manager

Usage: tq <COMMAND>

Commands:
  add   Add a new task
  list  List all tasks
  get   Show a task
  done  Mark a task done
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help     Print help
  -V, --version  Print version

-- отделяет аргументы Cargo от аргументов самой программы - без него Cargo попытался бы разобрать add как свою собственную команду.

$ cargo run -- add --help
Add a new task

Usage: tq add <TITLE>

Arguments:
  <TITLE>  Task title

Options:
  -h, --help  Print help

cargo run -- add "Buy coffee" добавляет задачу. cargo run -- list выводит список. cargo run -- done 1 завершает.

make ci проходит.

Примечание. make run -- list не работает: -- make забирает себе, программа его не получает. Аргументы передаются через переменную ARGS:

# Makefile - CHANGED
run:
	@cargo run -- $(ARGS)

После этого: make run ARGS="list", make run ARGS="add 'Buy coffee'".

Полный код tq для этой главы - 5-interface/01-the-voice-at-the-door/.


Lore: mod объявляет, use находит

В main.rs есть строки, которые fn main не использует напрямую:

mod persistence;
mod store;
mod task;

mod foo; - не импорт. Это объявление: “файл src/foo.rs существует и компилируется вместе с программой”. Все такие объявления живут в main.rs - точке входа, с которой компилятор начинает сборку. src/cli/mod.rs обращается к ним через crate::persistence, crate::store, crate::task - но только потому что main.rs сначала объявил их существование.

Убрать mod persistence; - и crate::persistence в src/cli/mod.rs перестанет существовать, хотя файл на диске никуда не денется.

Lore: как clap читает документацию

Строки /// Add a new task - doc-комментарии Rust. Компилятор хранит их как атрибут #[doc = "..."] на типе или поле. clap читает именно этот атрибут - не строки напрямую.

Следствие практическое: убрать комментарий - и вариант останется, но в --help появится пустое место. Не ошибка. Просто помощь без объяснений - состояние, которое встречается чаще, чем хотелось бы.