← Оглавление

6.1 Два крейта, один верстак

В мастерской, где инструменты сделаны под одну задачу, всё лежит вместе. Это удобно - пока задача одна. Когда появляется вторая, начинается разговор о том, что чьё и кто за чем тянется. Этот разговор не бывает коротким и всегда заканчивается перестановкой.

Что нужно

Весь tq - один крейт: задачи, хранилище, конфигурация, обработка ошибок и командный интерфейс - вместе. Пока это одна программа, это нормально.

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

Решение - Cargo workspace. Код, который нужен обоим, переезжает в библиотечный крейт tq-core. CLI остаётся в tq-cli, теперь зависящем от tq-core. Логика не изменится, тесты не изменятся. cargo run -p tq-cli будет делать ровно то, что cargo run делал раньше.

Сборка

По итогу проекта будет такая структура:

.
├── Cargo.toml
├── Makefile
└── crates/
    ├── core/
    │   ├── Cargo.toml
    │   └── src/
    │       ├── lib.rs
    │       ├── config/
    │       ├── error/
    │       ├── persistence/
    │       ├── store/
    │       └── task/
    └── cli/
        ├── Cargo.toml
        └── src/
            ├── main.rs
            └── cli/

Корневой Cargo.toml становится манифестом пространства, а не пакета. Всё, что в нём нужно:

# Cargo.toml - CHANGED
[workspace]
members = ["crates/core", "crates/cli"]
resolver = "2"

members - список крейтов. Cargo найдёт их Cargo.toml сам. resolver = "2" - версия алгоритма разрешения зависимостей; для workspace с edition 2021 и новее это стандартное значение.

Теперь создадим папки и перенесём файлы. Если у вас Linux или macOS, можно воспользоваться командами ниже - выполняются из корня проекта:

# создать папки для крейтов
$ mkdir -p crates/core/src crates/cli/src

# перенести модули в core
$ mv src/task src/store src/error src/persistence src/config crates/core/src/

# перенести main.rs и cli/ в tq-cli
$ mv src/main.rs crates/cli/src/
$ mv src/cli crates/cli/src/

# удалить теперь пустую папку
$ rmdir src

Директории есть. Теперь добавим манифесты в каждый крейт.

crates/core/Cargo.toml описывает библиотечный крейт. Clap здесь не нужен - командного интерфейса в core нет:

# crates/core/Cargo.toml - NEW
[package]
name = "tq-core"
version = "0.1.0"
edition = "2024"

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

crates/cli/Cargo.toml зависит от tq-core - не через crates.io, а прямо по пути:

# crates/cli/Cargo.toml - NEW
[package]
name = "tq-cli"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "tq"
path = "src/main.rs"

[dependencies]
clap = { version = "4", features = ["derive"] }
tq-core = { path = "../core" }

Модули task/, store/, error/, persistence/, config/ переехали в crates/core/src/ без изменений - код в них тот же, новое только место. Но одного файла там пока нет: lib.rs. Без него Cargo не знает, что crates/core - библиотечный крейт, и откажется собирать workspace. Создайте его сразу:

// crates/core/src/lib.rs - NEW
pub mod config;
pub mod error;
pub mod persistence;
pub mod store;
pub mod task;

Без pub модули остались бы закрытыми - tq-cli их не увидел бы.

В crates/cli/src/main.rs пять mod-деклараций уходят: эти модули больше не в этом крейте. Вместо них - импорты из tq_core:

// crates/cli/src/main.rs - CHANGED
mod cli;

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

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.data_dir.clone())?;
    cli::run(cli, config)
}

mod cli; остаётся - cli/mod.rs живёт в этом крейте.

В crates/cli/src/cli/mod.rs меняются только строки use. crate:: ссылалось на текущий крейт; нужные типы теперь в другом:

// crates/cli/src/cli/mod.rs - CHANGED (только строки use)
use tq_core::config::Config;             // was: use crate::config::Config
use tq_core::error::TqResult;           // was: use crate::error::TqResult
use tq_core::persistence;               // was: use crate::persistence
use tq_core::store::{Store, TaskStore}; // was: use crate::store::{Store, TaskStore}
use tq_core::task::Task;                // was: use crate::task::Task

Остальное в cli/mod.rs - структура Cli, перечисление Commands, функция run - не трогается.

Результат

$ mkdir -p /tmp/tq-m61
$ cargo run -p tq-cli -- --data-dir /tmp/tq-m61 add "Fix the latch"
#1: Fix the latch [Todo] (2026-06-06)

-p tq-cli - указание Cargo, какой бинарник запускать. В workspace без этого флага cargo run не знает, который из крейтов нужен.

make ci проходит. Тесты - те же 38, теперь распределены между двумя крейтами: 33 в tq-core, 5 в tq-cli.

Полный код tq для этой главы - 6-the-port/01-two-crates-one-toolbox/.


Lore: Библиотечный крейт

Крейт бывает двух видов. Бинарный ([[bin]] в Cargo.toml) производит исполняемый файл - у него есть main. Библиотечный ([lib]) - артефакт, который другие крейты импортируют; main у него нет, вход - lib.rs.

tq-core - библиотека: она ничего не запускает сама. tq-cli зависит от неё - и запускает.


Lore: Workspace

Workspace - несколько крейтов под одним корневым Cargo.toml. Корневой файл содержит только [workspace]; собственного пакета у него нет.

Команды без -p охватывают весь workspace: cargo build компилирует все крейты, cargo test гоняет тесты везде. С -p <name> - только указанный крейт.

Зависимости внутри workspace объявляются через path:

tq-core = { path = "../core" }

Cargo берёт крейт с диска, а не из реестра.