3.5 Cargo Workspaces: Managing Multiple Crates
Right, so you’ve graduated from a single crate. Congratulations. Your codebase now looks less like a neat little cottage and more like a sprawling, slightly concerning, hoarder’s mansion. Welcome to the big leagues, where you need a tool to manage the chaos. That tool is cargo workspace.
A workspace is Cargo’s way of saying, “I see you have a problem with creating too many separate projects. Let me help you organize that problem.” It’s a set of member crates—libraries and binaries—that all get built from the same top-level directory, sharing a common Cargo.lock file and target directory. This is the killer feature: no more compiling serde six separate times for six inter-related crates. Your CPU and your time will thank me.
The Minimal Workspace Setup
Let’s start simple. You don’t need a fancy tool to bootstrap this; you can craft it by hand like a proper artisan. Create a new directory and pop a Cargo.toml file in it. This isn’t a normal package manifest; it’s a virtual manifest. Its sole purpose is to point to the member crates.
mkdir my-grand-workspace && cd my-grand-workspace
touch Cargo.toml
Now, edit that top-level Cargo.toml. It’s beautifully sparse.
[workspace]
resolver = "2" # You want this. I'll explain why in a second.
members = [
"my-cool-library",
"my-cli-tool",
]
See? No [package] section. No dependencies. It’s just a signpost. Now, you can create the member crates. You’ll do this from inside the workspace directory so Cargo understands the hierarchy.
cargo new my-cool-library --lib
cargo new my-cli-tool
Your directory tree now looks like this:
my-grand-workspace/
├── Cargo.toml
├── my-cli-tool/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
└── my-cool-library/
├── Cargo.toml
└── src/
└── lib.rs
From the root of the workspace (my-grand-workspace/), running cargo build will build both crates. All output is magically funneled into a single target/ directory at the root. Run cargo build --workspace if you ever want to be painfully explicit.
Why the resolver = "2"?
Ah, yes. I told you I’d explain. This is a classic “good idea, rough edges” moment from the Cargo team. The default dependency resolver (“1”) can sometimes lead to dependency hell when you have a mix of library and binary crates that depend on each other and on different versions of the same underlying crate. It’s a mess.
The “2” resolver, often called the feature resolver, is smarter. It unifies dependencies for build and proc-macros but can avoid unifying features for normal dependencies across crates that don’t need it. This prevents a situation where a feature you enabled for one binary crate accidentally gets enabled for an unrelated library, potentially breaking things in bizarre and hard-to-debug ways. Just always set resolver = "2" in your workspace manifest. It saves you from conversations with your future self that start with, “Why on earth is this enabled here?!”
Adding Dependencies Between Members
This is the whole point. Let’s make the CLI tool depend on the library. Edit my-cli-tool/Cargo.toml:
[package]
name = "my-cli-tool"
version = "0.1.0"
edition = "2021"
[dependencies]
my-cool-library = { path = "../my-cool-library" } # This is the key line
Now, in my-cli-tool/src/main.rs, you can use your library.
use my_cool_library;
fn main() {
println!("Calling my library: {}", my_cool_library::awesome_function());
}
And in my-cool-library/src/lib.rs:
pub fn awesome_function() -> &'static str {
"This is indeed awesome."
}
Run it from the workspace root with cargo run -p my-cli-tool. The -p (or --package) flag is how you tell Cargo which specific member of the workspace you want to operate on. It’s your best friend.
The Shared Cargo.lock
This is the unsung hero. A single, top-level Cargo.lock means all your crates are resolved against the same exact versions of all their dependencies. This is non-negotiable for consistency. It guarantees that every member of the workspace is using, for example, serde 1.0.193 and not a mix of 1.0.193 and 1.0.194. This eliminates a whole class of “but it works on my machine” problems that are caused by slightly different dependency trees. It’s enforced. Cargo doesn’t ask; it just does it.
Workspace Best Practices and Pitfalls
Running Commands: You almost never want to
cdinto a member and runcargo build. You lose the workspace context. Always run commands from the root using-pto specify the package:cargo check -p my-cool-library,cargo test -p my-cli-tool,cargo publish -p my-cool-library.Dependency Management: While you can specify external dependencies in the top-level
Cargo.toml, don’t. It’s confusing and against convention. Each member’sCargo.tomlshould declare its own dependencies. The workspace manifest is for managing the members, not their dependencies.The
default-membersField: In your[workspace]section, you can adddefault-members = ["my-cli-tool"]. This tells Cargo that if you runcargo buildfrom the root without-p, it should only build that specific member. Useful if one of your members is a massive beast that takes minutes to compile and you’re only working on a smaller tool.The
excludeField: The opposite ofmembers. It’s a blacklist for directories you want Cargo to ignore. Use it sparingly; it’s usually a sign your project structure might need rethinking.
Workspaces are one of those features that feel a bit over-engineered at first glance until you desperately need them. Then, they become an indispensable part of your Rust toolkit. Now go forth, and organize your sprawling mansion of crates with the confidence of someone who has a single target directory to rule them all.