Skip to content
AgileLogics
5 min read

Cargo, Dependencies & Workspaces

Prerequisites: Modules, crates & packages

Cargo is Rust's build tool and package manager. Every package is described by a Cargo.toml manifest, and third-party libraries (called crates) come from the public registry at crates.io.

  • [package] names the package, its version and the Rust edition (the set of language rules it is compiled with).
  • [dependencies] lists crates your code uses. [dev-dependencies] lists crates needed only by tests, examples and benchmarks.
  • Cargo.lock records the exact version of every crate that was chosen, so every build uses the same code. Cargo creates and updates it for you; commit it to version control and don't edit it by hand.

Syntax

[package]
name = "weather"
version = "0.1.0"
edition = "2024"

[dependencies]
rand = "0.9"                                        # any 0.9.x (0.9.0 up to, not including, 0.10.0)
serde = { version = "1.0", features = ["derive"] }  # any 1.x from 1.0.0, with an optional feature on
greeting = { path = "../greeting" }                  # a crate on your own disk

[dev-dependencies]
pretty_assertions = "1"
cargo add rand                      # add the latest compatible version to Cargo.toml
cargo add serde --features derive   # add with a feature enabled
cargo remove rand                   # take it out again
cargo update                        # move to newer compatible versions, rewriting Cargo.lock
cargo tree                          # show the full dependency tree

Version requirements

A version like "1.4.2" is shorthand for ^1.4.2: Cargo may pick any semver-compatible release, meaning >=1.4.2 and <2.0.0. For versions below 1.0 the second number is treated as the breaking one, so "0.9" allows 0.9.x but not 0.10. cargo update never crosses a breaking boundary; changing to a new major version means editing Cargo.toml. Other forms exist ("=1.4.2" for an exact pin, ">=1.2, <1.5" for a range) but are rarely needed.

Features

A feature is a named switch that turns optional code on. Crates declare them in a [features] table and check them with cfg. Many crates keep extras behind features (serde's derive macro is the classic example), so if the documentation mentions something you can't find, check whether a feature is needed.

Workspaces

A workspace is a set of packages that share one Cargo.lock and one target/ build directory. It is the usual way to split a larger project into a library and an application.

Example

greet-workspace/
├── Cargo.toml
├── app/
│   ├── Cargo.toml
│   └── src/main.rs
└── greeting/
    ├── Cargo.toml
    └── src/lib.rs

greet-workspace/Cargo.toml (a workspace root has no [package]):

[workspace]
members = ["app", "greeting"]
resolver = "3"

greeting/Cargo.toml:

[package]
name = "greeting"
version = "0.1.0"
edition = "2024"

[features]
shout = []

greeting/src/lib.rs:

pub fn hello(name: &str) -> String {
    let text = format!("Hello, {name}!");
    if cfg!(feature = "shout") {
        text.to_uppercase()
    } else {
        text
    }
}

app/Cargo.toml:

[package]
name = "app"
version = "0.1.0"
edition = "2024"

[dependencies]
greeting = { path = "../greeting", features = ["shout"] }

app/src/main.rs:

fn main() {
    println!("{}", greeting::hello("Ada"));
}

Run it from the workspace root with cargo run -p app.

Expected output

HELLO, ADA!

Remove features = ["shout"] from app/Cargo.toml and the output becomes Hello, Ada!.

Common mistakes

  • Using "*" as a version. It accepts any release, including ones with breaking changes, and crates.io rejects packages that do it.
  • Forgetting a feature. With serde = "1.0" (no derive feature), #[derive(Serialize)] fails with error: cannot find derive macro `Serialize` in this scope.
  • Expecting cargo update to jump to a new major version. It only moves within the range your Cargo.toml allows.
  • Deleting Cargo.lock to "fix" a build. That silently upgrades every dependency at once and makes bugs hard to trace.
  • Running cargo run in a workspace root that has several binaries without -p <package> to say which one to run.

Practice

Create a new package, add the rand crate at version 0.9 with cargo add, and print a random dice roll between 1 and 6.

Show solution
cargo new dice
cd dice
cargo add rand@0.9

src/main.rs:

fn main() {
    let roll: u8 = rand::random_range(1..=6);
    println!("You rolled {roll}");
}

The number changes on each run, for example You rolled 4.