6.1 Two Crates, One Toolbox
In a workshop where the tools are built for one task, everything sits together. That is convenient - while the task is one. When a second arrives, the conversation begins about what belongs to whom and who is reaching for what. That conversation is never short and always ends with a rearrangement.
What You Need
All of tq is a single crate: tasks, the store, configuration, error handling, and the
command interface - together. While this is one program, that is fine.
But the moment tq must also manage tasks over the web, a second solution is needed. A web
server is a separate binary; it does not need the command-line capabilities. But the logic
is the same: the same tasks, the same store, the same config. Two binary crates cannot
share code directly - each compiles into a separate artifact.
The solution is a Cargo workspace. Code needed by both moves into a library crate,
tq-core. The CLI stays in tq-cli, now depending on tq-core. The logic will not
change, the tests will not change. cargo run -p tq-cli will do exactly what cargo run
did before.
The Build
The final project structure:
.
├── Cargo.toml
├── Makefile
└── crates/
├── core/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── config/
│ ├── error/
│ ├── persistence/
│ ├── store/
│ └── task/
└── cli/
├── Cargo.toml
└── src/
├── main.rs
└── cli/
The root Cargo.toml becomes a workspace manifest, not a package manifest. All it
needs:
# Cargo.toml - CHANGED
[workspace]
members = ["crates/core", "crates/cli"]
resolver = "2"
members - the list of crates. Cargo will find their Cargo.toml files on its own.
resolver = "2" - the version of the dependency resolution algorithm; for workspaces with
edition 2021 and later this is the standard value.
Now create the directories and move the files. On Linux or macOS these commands can be run from the project root:
# create crate directories
$ mkdir -p crates/core/src crates/cli/src
# move modules into core
$ mv src/task src/store src/error src/persistence src/config crates/core/src/
# move main.rs and cli/ into tq-cli
$ mv src/main.rs crates/cli/src/
$ mv src/cli crates/cli/src/
# remove the now-empty directory
$ rmdir src
The directories are in place. Now add a manifest to each crate.
crates/core/Cargo.toml describes the library crate. clap is not needed here - there is
no command interface in 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 depends on tq-core - not through crates.io, but directly by
path:
# 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" }
The modules task/, store/, error/, persistence/, config/ moved to
crates/core/src/ unchanged - the code in them is the same, only the location is new. But
one file is still missing: lib.rs. Without it Cargo does not know that crates/core is a
library crate and will refuse to build the workspace. Create it now:
// crates/core/src/lib.rs - NEW
pub mod config;
pub mod error;
pub mod persistence;
pub mod store;
pub mod task;
Without pub the modules would remain private - tq-cli would not see them.
In crates/cli/src/main.rs the five mod declarations are removed: those modules are no
longer in this crate. In their place - imports from 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; stays - cli/mod.rs lives in this crate.
In crates/cli/src/cli/mod.rs only the use lines change. crate:: referred to the
current crate; the needed types are now in another:
// crates/cli/src/cli/mod.rs - CHANGED (use lines only)
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
Everything else in cli/mod.rs - the Cli struct, the Commands enum, the run
function - stays untouched.
The Result
$ 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 tells Cargo which binary to run. In a workspace without this flag, cargo run
does not know which crate is wanted.
make ci passes. The tests - the same 38, now distributed across two crates: 33 in
tq-core, 5 in tq-cli.
The complete
tqcode for this chapter is in6-the-port/01-two-crates-one-toolbox/.
Lore: The Library Crate
A crate comes in two kinds. A binary crate ([[bin]] in Cargo.toml) produces an
executable - it has a main. A library crate ([lib]) is an artifact that other crates
import; it has no main, and its entry point is lib.rs.
tq-core is a library: it runs nothing itself. tq-cli depends on it - and runs.
Lore: Workspace
A workspace is several crates under one root Cargo.toml. The root file contains only
[workspace]; it has no package of its own.
Commands without -p cover the entire workspace: cargo build compiles all crates, cargo test runs tests everywhere. With -p <name> - only the named crate.
Dependencies inside a workspace are declared with path:
tq-core = { path = "../core" }
Cargo takes the crate from disk, not from the registry.