How I Use Cargo Workspace to structure my Rust projects
Table of Contents
I’ve learn to use Cargo Workspace from Olivia’s post in bluesky, which you should checkout. This article is a way of self-documenting since I’ve come back to it more often.
What is a Cargo Workspace
A cargo workspace is collection of one or more library or binary crates. Cargo workspace let’s you break your rust project into multiple different crates (or packages), which may aid you in better project structure and management.
Why use Cargo Workspace
- Your rust project has grown big and is hard to manage
- Multiple people work on a project, so breaking your project into multiple crates can help with better ownership and less conflict among developers
- You want to reduce your project’s compile time
Read this (external link): Cutting Down Rust Compile Times From 30 to 2 Minutes With One Thousand Crates
Creating a Workspace
Create a new workspace directory
mkdir runner
cd runner
Now create Cargo.toml with following content
[workspace]
resolver = "3"
Here we are using resolver version 3, which is the latest version and comes in Rust 2024 edition (Rust 1.84+). Resolver tells cargo how to resolve the dependencies.
Create a binary crate
We are going to use cargo new command to create a binary crate. When no argument is passed, cargo new command will create binary crate by default, you can use --lib as argument to create library crate or --bin to explicit that you want binary crate.
cargo new run
Now your workspace will look like this:
.
├── Cargo.toml
└── run
├── Cargo.toml
└── src
└── main.rs
If you look at your workspace’s Cargo.toml you will notice that cargo has already added the newly added crate run against the members key.
[workspace]
resolver = "3"
members = ["run"]Adding lib crates
Let’s add two lib crates
cargo new participant --lib
cargo new track --lib
Updated structure:
.
├── Cargo.lock
├── Cargo.toml
├── participant
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── run
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── track
├── Cargo.toml
└── src
└── lib.rsAdding dependencies
If you try to simply add a dependency like this
cargo add uuid --features v4
‼️ it will fail and you will see this error
error: `cargo add` could not determine which package to modify. Use the `--package` option to specify a package.
available packages: participant, run, track
Since inside our Cargo workspace we have 3 packages, therefore cargo is unable to determine to which package dependency belongs to. ✅ We can solve this by using --package arg.
cargo add uuid --features v4 --package participantDependency Resolution
Since in cargo workspace we can have multiple crates, which means we have to add dependency to each crate separately and in that sometimes there may be a version mismatch.
Like with uuid in participant crate, it is using version 1.18.1 and now you if you try to add uuid of different version in run crate, for example if we want to stick to only 1.17 version of the uuid crate:
cargo add uuid@=1.17 --features v4 --package run
But wait, we got error
error: failed to select a version for `uuid`.
... required by package `run v0.1.0 (/home/vivek/Documents/code/hands-on/runner/run)`
versions that meet the requirements `=1.17` are: 1.17.0
all possible versions conflict with previously selected packages.
previously selected package `uuid v1.18.1`
... which satisfies dependency `uuid = "^1.18.1"` of package `participant v0.1.0 (/home/vivek/Documents/code/hands-on/runner/participant)`
failed to select a version for `uuid` which could resolve this conflict
It’s because a Cargo workspace can only have one Cargo.lock file, which is shared between all the members of the workspace, so it can only have one version of the dependency across the workspace. So instead of pinning to =1.17 version of uuid, we had used just 1.17, cargo would have choosen [email protected] since that was already being used by participant crate.
In order to resolve the dependency across workspace, cargo will try to find the most suitable version of a crate, mostly by downgrading to the appropriate version.
Better way to declare dependency
It’s very normal to have common dependencies across crates, that is why Cargo Workspace let us declare dependencies in workspace’s Cargo.toml and then you can re-use the same dependency inside of your crate.
# Cargo.toml
[workspace.dependencies]
uuid = { version = "=1.17", features = ["v4"] }
And then in your crates you can use the dependency with:
# participant/Cargo.toml
[dependencies]
uuid = { workspace = true }
# run/Cargo.toml
[dependencies]
uuid = { workspace = true }Importing members as dependencies
A common way to import a crate (or package) inside another crate is like this:
# run/Cargo.toml
[dependencies]
participant = { path = "../participant" }
track = { path = "../track" }
# track/Cargo.toml
[dependencies]
participant = { path = "../participant" }
But now if you had to do this for each of the crate in your project and sometime there can be a lot of crates. Also the path is relative to the current Cargo.toml file, so if you dare re-structure your workspace, it can backfire and you will have another mess to deal with.
However we can use the same way of delcaring the dependency in the workspace’s Cargo.toml and then just use it inside the members by telling cargo to use workspace’s dependency.
# Cargo.toml
[workspace]
resolver = "3"
members = ["participant", "run", "track"]
[workspace.dependencies]
participant = { path = "participant" }
track = { path = "track" }
# run/Cargo.toml
[dependencies]
participant = { workspace = true }
track = { workspace = true }
# track/Cargo.toml
[dependencies]
participant = { workspace = true }Multiple Binaries
When you have multiple binaries in your workspace, cargo run won’t work since it doesn’t know which binary crate to run. So instead explicitly pass the crate name:
cargo run --package my_bin_crateCheat sheet
Create workspace:
# Cargo.toml
[workspace]
resolver = "3"
Adding bin and lib to workspace:
# creates binary crate
cargo new my_bin
# crates lib crate
cargo new my_lib --lib
Importing dependencies:
# Add dependencies in workspace's Cargo.toml
[workspace.dependencies]
uuid = { version = "=1.17", features = ["v4"] }
# import in members like this: my_lib/Cargo.toml
[dependencies]
uuid = { workspace = true }
Import members like this:
# Cargo.toml
[workspace]
resolver = "3"
members = ["my_bin", "my_lib"]
[workspace.dependencies]
my_lib = { path = "my_lib" }
# my_bin/Cargo.toml
[dependencies]
my_lib = { workspace = true }
Other commands:
# run if multiple binaries exist
cargo run --package my_bin
# build specific binary
cargo build --package my_bin
# build all
cargo build --all
# test all
cargo test --all