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 берёт крейт с диска, а не из реестра.