09 · Workspace Organization¶
A single Cargo.toml works until a project grows a shared library, a CLI,
and an HTTP API that all need the same core types — at that point you want
one workspace with multiple crates, a single Cargo.lock shared across all
of them, and dependency versions declared once instead of copy-pasted per
crate. This module builds a real three-crate workspace: core (shared
types), cli, and api, both depending on core.
Layout¶
workspace-demo/
├── Cargo.toml # [workspace] root, no [package]
└── crates/
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
├── cli/
│ ├── Cargo.toml
│ └── src/main.rs
└── api/
├── Cargo.toml
└── src/main.rs
The workspace root¶
# Cargo.toml
[workspace]
resolver = "2"
members = ["crates/core", "crates/cli", "crates/api"]
[workspace.package]
version = "0.1.0"
edition = "2021"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
The root Cargo.toml has no [package] section — it's a workspace
manifest, not a crate itself. [workspace.package] and
[workspace.dependencies] are the two things member crates inherit from
instead of repeating: version numbers and shared dependency specs declared
once, at one version, for the whole workspace.
core — the shared library crate¶
# crates/core/Cargo.toml
[package]
name = "core"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
// crates/core/src/lib.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: u32,
pub title: String,
}
pub fn new_task(id: u32, title: &str) -> Task {
Task { id, title: title.to_string() }
}
version.workspace = true and serde.workspace = true are the inheritance
syntax — this crate doesn't hardcode "0.1.0" or serde = "1" anywhere;
both come from the root. Bump serde's version once in the workspace root
and every member that opted in with .workspace = true picks it up.
cli and api — both depend on core by path¶
# crates/cli/Cargo.toml
[package]
name = "cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "tasks-cli"
path = "src/main.rs"
[dependencies]
core = { path = "../core" }
// crates/cli/src/main.rs
fn main() {
let task = core::new_task(1, "write workspace docs");
println!("{task:?}");
}
core = { path = "../core" } is a path dependency, not a crates.io
version — Cargo resolves it to the sibling crate directly, so an edit to
core's source is picked up by both cli and api the next time either
builds, no publishing or version bump required.
Running it¶
$ cargo run -p cli
Task { id: 1, title: "write workspace docs" }
$ cargo run -p api
{"id":2,"title":"serve tasks over http"}
-p <crate> selects which workspace member to run — from the workspace
root, plain cargo run is ambiguous once there's more than one binary and
Cargo will ask you to disambiguate.
Seeing the shared dependency tree¶
$ cargo tree -p cli
cli v0.1.0 (/workspace-demo/crates/cli)
└── core v0.1.0 (/workspace-demo/crates/core)
└── serde v1.0.229
├── serde_core v1.0.229
└── serde_derive v1.0.229 (proc-macro)
├── proc-macro2 v1.0.107
│ └── unicode-ident v1.0.24
├── quote v1.0.47
│ └── proc-macro2 v1.0.107 (*)
└── syn v3.0.3
Only one serde v1.0.229 appears anywhere in the tree, and only one
Cargo.lock exists at the workspace root — cli and api both depending
on serde (transitively through core, and directly in api's case)
resolve to the same compiled version, compiled once and shared, instead of
each crate potentially locking a slightly different serde minor version
the way two independent projects could.
Rust-specific traps¶
One shared Cargo.lock, at the workspace root, not per crate. Member
crates don't get their own lock file — cargo build from anywhere inside
the workspace resolves against (and updates) the single root
Cargo.lock. Committing a lock file inside crates/core/ by accident (from
running cargo build there directly on an older Cargo, or from a stray
tool) is a common source of "why does CI show a diff in a file I never
touched."
resolver = "2" isn't automatic on older edition. Workspaces created
before the 2021 edition default to the old (version-1) feature resolver
unless resolver = "2" is set explicitly at the workspace root — the
version-1 resolver unifies feature flags across dev/build/normal
dependencies in a way that can silently enable a feature in a release build
that was only meant for tests. New workspaces created with a recent edition
get resolver 2 by default; older ones migrating need the line added by hand.
Path dependencies don't imply a version bump obligation. core = {
path = "../core" } compiles against whatever's on disk right now — there's
no separate "did I bump core's version" step required for cli to see a
change, unlike depending on a crates.io-published version. This is
convenient during development and a real trap the moment core gets
published separately: forgetting to also bump core's version for a
breaking change means downstream consumers pulling from crates.io (not the
workspace) silently get stale behavior.
cargo test from the root runs every member's tests. Plain cargo
test with no -p flag runs tests across the whole workspace — fine for CI,
surprising the first time a change to api triggers core's and cli's
test suites too because they're all one cargo test invocation.
Cheat sheet¶
| Command | Effect |
|---|---|
cargo build (from root) |
Builds every workspace member |
cargo run -p cli |
Runs the cli member's binary specifically |
cargo test -p core |
Tests only the core member |
cargo tree -p cli |
Shows cli's full dependency tree, deduped |
version.workspace = true |
Inherits [workspace.package].version |
dep.workspace = true |
Inherits the version/features from [workspace.dependencies] |
core = { path = "../core" } |
Local sibling-crate dependency, always current |
How It Actually Works¶
A workspace's single Cargo.lock reflects that Cargo treats all member
crates as one unified dependency-resolution problem, not several
independent ones sharing a directory: when computing what version of
serde to use, Cargo walks the combined dependency graph of every member
crate at once and picks one set of versions satisfying every member's
constraints simultaneously, then compiles each shared dependency exactly
once as a single .rlib artifact all requesting crates link against. This
is also why resolver = "2" matters: the older (version-1) resolver unifies
feature flags across a crate's normal, dev, and build dependency roles as if
they were one dependency, so a feature enabled only for cargo test's
dev-dependencies could leak into what gets compiled for the release binary
too — resolver 2 tracks those roles separately during resolution, which is
a real behavioral difference in what code ends up linked into a release
build, not just a metadata change.
Path dependencies compiling against on-disk source with no version-bump
requirement is the flip side of the crates.io publishing model: a
crates.io dependency is fetched and locked to an immutable, versioned
snapshot of source published at some point in the past, so bumping a
version is literally how you tell downstream consumers "there's new source
to fetch." A path dependency instead points Cargo directly at a live
directory on your filesystem — there's no snapshot step at all, so every
cargo build simply recompiles against whatever bytes are on disk right
now, which is exactly why the workspace's fast within-repo iteration loop
and the crates.io-consumer's need for explicit version bumps are two
different mechanisms, not the same one behaving differently.
Exercise¶
Add a fourth workspace member, crates/shared-test-utils, containing a
pub fn sample_task() -> core::Task helper, and add it as a
[dev-dependencies] entry (not a regular dependency) in both cli and
api's Cargo.toml. Confirm with cargo tree -p cli -e dev that it shows
up only in the dev-dependency tree, and explain why a [dev-dependencies]
entry doesn't get compiled into cli's release binary.