EthicsComputerCourses
Join training +91 90532 10052

Cargo Workspaces, Features & Profiles

Rust · Lesson 20 of 43 Intermediate 11 min read Updated

A beginner project is one package with a src/main.rs. A real product soon grows: a core library, a command-line tool, a web service and a few helper programs, all sharing code and dependency versions. Cargo handles this with workspaces. Two more Cargo tools matter as a project grows: features, which let users switch optional parts of a crate on and off, and profiles, which control how the code is compiled.

This lesson builds on Cargo: New, Run, Build & Check and Modules, Crates & Visibility. You will learn the standard layout of a package, build a two-crate workspace step by step, add an optional feature, and tune build profiles for speed and size.

The standard package layout

Cargo finds most files by convention, so you rarely configure paths. A full package can contain:

PathWhat Cargo does with it
src/main.rsThe default binary, named after the package
src/lib.rsThe library crate, usable by binaries, tests and other packages
src/bin/report.rsAn extra binary called report, run with cargo run --bin report
tests/*.rsIntegration tests, each file compiled as a separate crate
benches/*.rsBenchmarks, run with cargo bench
examples/*.rsExample programs, run with cargo run --example name
build.rsAn optional build script that runs before compiling the package

A package may have at most one library but any number of binaries. The common pattern from the modules lesson, real logic in lib.rs and a thin main.rs, is what makes this layout useful: tests, benchmarks, examples and every binary all reuse the same library.

What a workspace is

A workspace is a set of packages, called members, that are developed together. Members share:

  • one Cargo.lock, so every crate uses exactly the same dependency versions;
  • one target/ folder, so a dependency such as serde is compiled once, not once per package;
  • the [profile] settings of the root Cargo.toml (profiles in member manifests are ignored).

The root Cargo.toml can be a normal package with an added [workspace] table, or a virtual manifest that has a [workspace] table but no [package]. Most multi-crate projects use a virtual manifest with the members in a crates/ folder.

Step by step: a railway workspace

You will build railway, with a library fares that calculates ticket prices and a binary ticket-cli that uses it. The finished folder looks like this:

Text
railway/
  Cargo.toml          workspace root (virtual manifest)
  Cargo.lock          shared by all members
  crates/
    fares/            library
      Cargo.toml
      src/lib.rs
    ticket-cli/       binary
      Cargo.toml
      src/main.rs
  target/             shared build output
  1. Create the folder railway and, inside it, the root Cargo.toml:
TOML
[workspace]
members = ["crates/*"]
resolver = "3"

[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[workspace.lints.rust]
unsafe_code = "forbid"

[workspace.lints.clippy]
unwrap_used = "warn"

members = ["crates/*"] is a glob: every folder in crates becomes a member. A virtual manifest has no edition from which to guess the dependency resolver, so you must set resolver = "3" yourself (resolver 3 needs Rust 1.84 or newer and prefers dependency versions that support your rust-version). [workspace.package] holds values that members can inherit, [workspace.dependencies] declares each shared dependency and its version once, and [workspace.lints] sets lint levels for the whole team.

  1. From the railway folder, create the two members:
Terminal
cargo new crates/fares --lib
cargo new crates/ticket-cli
  1. Replace crates/fares/Cargo.toml so it inherits from the workspace:
TOML
[package]
name = "fares"
version.workspace = true
edition.workspace = true
rust-version.workspace = true

[dependencies]
serde = { workspace = true, optional = true }

[features]
json = ["dep:serde"]

[lints]
workspace = true

version.workspace = true means "use the value from [workspace.package]". The dependency line says workspace = true instead of a version, so the version and the derive feature come from the root; a member may add only features, optional or default-features next to it. [lints] workspace = true opts this crate into the shared lint levels. The [features] table is explained in the next section.

  1. Write the library in crates/fares/src/lib.rs:
Rust
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "json", derive(serde::Serialize))]
pub enum Class {
    Sleeper,
    ChairCar,
    ThirdAc,
}

#[derive(Debug)]
#[cfg_attr(feature = "json", derive(serde::Serialize))]
pub struct Quote {
    pub class: Class,
    pub km: u32,
    pub fare: u32,
}

pub fn quote(class: Class, km: u32) -> Quote {
    let (per_km, reservation) = match class {
        Class::Sleeper => (1, 20),
        Class::ChairCar => (2, 40),
        Class::ThirdAc => (3, 40),
    };
    Quote { class, km, fare: per_km * km + reservation }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sleeper_fare_includes_reservation() {
        assert_eq!(quote(Class::Sleeper, 300).fare, 320);
    }
}

#[cfg_attr(feature = "json", derive(serde::Serialize))] means "add this derive only when the json feature is on". Without the feature, serde is not even downloaded or compiled.

  1. Make ticket-cli depend on the library with the feature turned on. In crates/ticket-cli/Cargo.toml, below the same inherited [package] keys, add:
TOML
[dependencies]
fares = { path = "../fares", features = ["json"] }
serde_json.workspace = true

[lints]
workspace = true
  1. Write crates/ticket-cli/src/main.rs:
Rust
use fares::{quote, Class};

fn main() {
    let trip = quote(Class::ThirdAc, 450);
    println!("{trip:?}");
    match serde_json::to_string(&trip) {
        Ok(json) => println!("{json}"),
        Err(e) => eprintln!("error: {e}"),
    }
}
  1. Build, run and test from the railway folder:
Terminal
cargo build                  # builds every member
cargo run -p ticket-cli      # -p picks one package
cargo test -p fares          # tests only the library
cargo test --workspace       # tests every member
Output
Quote { class: ThirdAc, km: 450, fare: 1390 }
{"class":"ThirdAc","km":450,"fare":1390}

The fare is 3 x 450 + 40 = 1390. A unit enum variant becomes a JSON string, so class appears as "ThirdAc". Only one Cargo.lock and one target/ folder exist, at the root.

Features in detail

A feature is a named switch defined in a package's [features] table. Code checks it with #[cfg(feature = "name")] or cfg_attr, and Cargo passes the enabled features to the compiler.

  • Each feature lists what it turns on: other features, or optional dependencies written as "dep:name".
  • default = ["a", "b"] lists features that are on unless the user writes default-features = false (or passes --no-default-features).
  • A dependency marked optional = true is compiled only when a feature enables it. Writing dep:serde hides the dependency name, so users must use your feature name json. The dep: syntax needs Rust 1.60 or newer.
  • "serde/rc" turns on a feature of a dependency (and the dependency itself if it is optional); "serde?/rc" turns it on only if something else already enabled serde.

On the command line, --features json (comma-separated for several), --all-features and --no-default-features change what is built:

Terminal
cargo build -p fares --features json
cargo test -p fares --all-features

Features are unified: if two crates in the build depend on fares and only one asks for json, fares is compiled once with json on. Because of this, features must be additive. Turning a feature on may add functions or trait impls, but must never remove or change anything another crate relies on. A feature called no_std that removes code is a mistake; the convention is a std feature that adds code.

Unification also explains a surprise in workspaces: cargo build at the root builds fares with json (because ticket-cli asks for it), while cargo test -p fares builds it without. Test your library with --all-features and with no features in your CI so both combinations stay working.

Build profiles

A profile is a set of compiler settings. cargo build uses dev, cargo build --release uses release, and tests and benchmarks use profiles based on those two. The most important defaults are:

SettingdevreleaseWhat it controls
opt-level03How hard the compiler optimises
debugtruefalseDebug information for debuggers and profilers
overflow-checkstruefalsePanic on integer overflow
incrementaltruefalseReuse work between builds
codegen-units25616Parallel pieces; more is faster to build, fewer may run faster
ltofalsefalseLink-time optimisation across crates

You override profiles in the root Cargo.toml. A typical setup for a shipped command-line tool:

TOML
[profile.release]
lto = true            # optimise across all crates at link time
codegen-units = 1     # slower build, often a faster and smaller program
panic = "abort"       # no unwinding code; a panic ends the process
strip = true          # remove symbols for a smaller file

[profile.dev.package."*"]
opt-level = 2         # optimise dependencies even in debug builds

[profile.profiling]
inherits = "release"
debug = true          # release speed, but with symbols for a profiler

[profile.dev.package."*"] applies to every dependency that is not a workspace member. It makes debug builds of programs that do heavy work in libraries (image processing, parsing) much faster to run, while your own code still builds quickly. A custom profile such as profiling must say which profile it inherits from; build it with cargo build --profile profiling, and the output goes to target/profiling/.

Common mistakes

  • Putting [profile] in a member's Cargo.toml. Cargo ignores it there (with a warning). Profiles belong in the workspace root.
  • Leaving out resolver in a virtual manifest. Cargo then falls back to the old resolver and warns. Write resolver = "3".
  • Writing a version next to workspace = true. Inherited dependencies may only add features, optional and default-features.
  • Non-additive features. If enabling a feature removes code, some combination of crates will fail to compile. Features should only add.
  • Testing only the default feature set. Run your tests with --all-features and --no-default-features too.

Key points

  • Cargo finds binaries, tests, benches and examples by folder convention: src/bin/, tests/, benches/, examples/.
  • A workspace shares one Cargo.lock, one target/ folder and the root's profiles; select packages with -p name or --workspace.
  • [workspace.package], [workspace.dependencies] and [workspace.lints] define values once; members inherit them with workspace = true.
  • Features are additive switches in [features]; optional dependencies are enabled with dep:name, and code checks them with #[cfg(feature = "...")].
  • Features are unified across the build, so a crate is compiled once with every feature any dependent asked for.
  • Profiles (dev, release, custom ones with inherits) control optimisation, debug info, LTO, panic strategy and stripping.

Exercise

Extend the railway workspace. Add a feature called senior to fares that compiles an extra function pub fn senior_fare(q: &Quote) -> u32, which returns 60 percent of the normal fare (whole rupees, rounded down). Turn the feature on from ticket-cli and print the senior fare for the 450 km Third AC trip. Finally, add a release profile that strips symbols and uses thin LTO, and write the command that builds only ticket-cli in release mode.

Show solution
TOML
# crates/fares/Cargo.toml
[features]
json = ["dep:serde"]
senior = []

# crates/ticket-cli/Cargo.toml
[dependencies]
fares = { path = "../fares", features = ["json", "senior"] }

# railway/Cargo.toml (workspace root)
[profile.release]
lto = "thin"
strip = true
Rust
// crates/fares/src/lib.rs (added)
#[cfg(feature = "senior")]
pub fn senior_fare(q: &Quote) -> u32 {
    q.fare * 60 / 100
}

// crates/ticket-cli/src/main.rs
use fares::{quote, senior_fare, Class};

fn main() {
    let trip = quote(Class::ThirdAc, 450);
    println!("Normal fare: Rs. {}", trip.fare);
    println!("Senior fare: Rs. {}", senior_fare(&trip));
}
Terminal
cargo build -p ticket-cli --release
Output
Normal fare: Rs. 1390
Senior fare: Rs. 834

senior = [] is a feature that enables nothing else; it only switches on the #[cfg(feature = "senior")] code. 1390 x 60 / 100 is 834 in integer arithmetic. The profile goes in the root because members' profiles are ignored.

Quick Quiz

1. What do all members of a Cargo workspace share?

2. Two crates depend on fares; one enables the json feature and the other does not. How is fares built when both are in the same build?

3. What does json = ["dep:serde"] in [features] do?

4. Where must [profile.release] settings go in a workspace?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call