RustArena

Step 7 of 7

Cargo: dependencies, versions, features, workspaces

Real projects stand on other people's code. crates.io is Rust's public registry of crates, and Cargo downloads, builds and links them for you. You list what you need in Cargo.toml:

[package]
name = "inventory"
version = "0.3.1"
edition = "2024"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
regex = "1.11"
itertools = "0.14"

This site's compiler has a few popular crates available, as if they were in your Cargo.toml (serde, serde_json, anyhow, thiserror, tokio, regex, itertools, rayon, chrono). For example, itertools adds extra iterator methods:

use itertools::Itertools;

fn main() {
    let scores = vec![30, 10, 20, 10];
    let line = scores.iter().sorted().dedup().join(" < ");
    println!("{line}");
}
10 < 20 < 30

use itertools::Itertools; brings a trait into scope. A trait's methods can only be called where the trait is in scope; that's why crates offer preludes, and why forgetting the use gives "no method named sorted found".

Semantic versioning

Crate versions are MAJOR.MINOR.PATCH, and the numbers carry a promise (semver):

In Cargo.toml, regex = "1.11" doesn't mean exactly 1.11. It's a caret requirement (^1.11): any version that semver says is compatible.

Requirement Allows
"1.11.2" (same as "^1.11.2") >= 1.11.2 and < 2.0.0
"0.14" >= 0.14.0 and < 0.15.0
"0.0.3" exactly 0.0.3
"~1.11.2" (tilde) >= 1.11.2 and < 1.12.0
"=1.11.2" exactly 1.11.2 (rarely a good idea)

Cargo uses one version per semver-compatible range in a build: if two of your dependencies need regex 1.9 and regex 1.11, both get the newest 1.x. Incompatible majors (1.x and 2.x) can live side by side. As a library author, the promise is on you: removing a pub item, changing a public function's signature or adding a field to a public struct whose fields are all pub (users build it with a struct literal, which now misses a field) is a breaking change that needs a new major version. cargo-semver-checks can catch these before you publish.

Features

Features are optional parts of a crate that you switch on. serde = { version = "1", features = ["derive"] } turns on serde's derive macros. Your own crate can define features too:

[features]
default = ["color"]          # on unless the user opts out
color = []                   # a plain on/off switch
json = ["dep:serde_json"]    # turning it on pulls in an optional dependency

[dependencies]
serde_json = { version = "1", optional = true }
#[cfg(feature = "json")]
pub fn to_json(report: &Report) -> String {
    serde_json::to_string(report).unwrap()
}

Code under #[cfg(feature = "json")] is compiled only when the feature is on: cargo build --features json, or --no-default-features to turn off the defaults. Features should be additive: turning one on may add things, never break or remove them, because Cargo merges the features everybody in the build asks for.

Other dependency sections: [dev-dependencies] are only for tests, examples and benchmarks; [build-dependencies] are for build scripts.

Workspaces

A workspace is several related packages developed together: one shared Cargo.lock, one target/ folder, one command to build or test everything.

shop/
├── Cargo.toml          the workspace
├── shop-core/          library: domain logic
│   ├── Cargo.toml
│   └── src/lib.rs
└── shop-cli/           binary: the command-line tool
    ├── Cargo.toml
    └── src/main.rs
# shop/Cargo.toml
[workspace]
members = ["shop-core", "shop-cli"]
resolver = "3"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
# shop-cli/Cargo.toml
[package]
name = "shop-cli"
version = "0.1.0"
edition = "2024"

[dependencies]
shop-core = { path = "../shop-core" }
serde = { workspace = true }

cargo build at the top builds every member; cargo test -p shop-core tests one package; cargo run -p shop-cli runs the tool. In shop-cli's code, the library is shop_core::. Most company codebases with more than one crate are workspaces.

Publishing on crates.io

  1. Add metadata to [package]: description, license (often "MIT OR Apache-2.0"), repository, and optionally readme, keywords, categories.
  2. Document the public API with /// comments: docs.rs builds and hosts the documentation of every published crate.
  3. cargo login with an API token from your crates.io account.
  4. cargo publish --dry-run to check, then cargo publish.
  5. A published version is permanent: it can't be overwritten or deleted. To fix a mistake, publish a new version. cargo yank --version 1.0.1 stops new projects from choosing a broken version, while existing Cargo.locks keep working.

Most code at work is never published. Teams depend on internal crates through workspaces, path dependencies, git dependencies (my-lib = { git = "https://..." }) or a private registry.

Your task

Put Cargo's caret rule into code. Inside a module semver, write a private helper parse that turns "1.4.2" into the tuple (1, 4, 2), and a public caret_matches(req: &str, version: &str) -> bool: does version satisfy the caret requirement req? Both are full MAJOR.MINOR.PATCH strings. The rules:

Tuples compare element by element, so (1, 4, 2) <= (1, 10, 0) is true, which is exactly what version comparison needs (comparing the strings would get "1.10.0" < "1.4.2" wrong).

Challenge 2

Add tilde requirements to the module: pub fn tilde_matches(req: &str, version: &str) -> bool. ~1.4.2 allows >= 1.4.2 and < 1.5.0: the version must be at least req, with the same major and minor. parse is already written; use it.

Challenge 3

Use a real dependency. This site's compiler has itertools (as if Cargo.toml had itertools = "0.14"). Write fn leaderboard_line(scores: &[(&str, u32)]) -> String that returns the players as "name:score" joined with ", ", highest score first, equal scores by name A to Z. For example [("bo", 75), ("ann", 90), ("cy", 75)] gives "ann:90, bo:75, cy:75".

Bring the Itertools trait into scope and use its iterator methods sorted_by(|a, b| ...) (sorts and gives back an iterator) and join(", ") (joins displayable items into a String). No loops.

Previous: Splitting a crate into files