← Table of contents

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 tq code for this chapter is in 6-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.