Vivek Shukla

How I Use Cargo Workspace to structure my Rust projects

Updated on

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.rs

πŸ”—Adding 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 participant

πŸ”—Dependency 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_crate

πŸ”—Cheat 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