Workspace and Project Management

30 min read
On this page

A workspace allows multiple related packages to share dependency resolution records and build management entry points. It is suitable for projects where core libraries, command-line programs, and servers evolve together, but it does not automatically eliminate duplicate dependency versions, nor does it require all members to be released together.

When designing splits, prioritize interfaces and responsibilities: should a change in one crate affect another? Can it be tested independently? Does it need to be published externally? Too many small crates also increase maintenance and compilation boundary costs.

Root Cargo.toml and Cargo.lockMember list, inherited configuration, unified resolution recordscalc-core: Library packageMaintains reusable business interfacesdemo-cli: Binary packageDepends on calc-core via path dependencyWorkspace shared management entry, members are still independent packagesA shared target directory does not guarantee that each dependency is compiled only once, nor that the dependency graph has only one version.

A Complete Two-Member Project

Root Cargo.toml:

[workspace]
members = ["crates/calc-core", "crates/demo-cli"]
default-members = ["crates/demo-cli"]
resolver = "3"

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
calc-core = { path = "crates/calc-core", version = "0.1.0" }

The root only has [workspace], making it a virtual manifest. resolver=3 was introduced alongside Rust 2024 and requires Rust/Cargo 1.85 or higher; virtual workspaces have no root package edition to infer from, so it should be explicitly selected. resolver=3 applies appropriate fallback strategies for dependencies incompatible with rust-version, but this does not replace testing with the minimum toolchain. Dependency Resolver

crates/calc-core/Cargo.toml:

[package]
name = "calc-core"
version.workspace = true
edition.workspace = true

crates/calc-core/src/lib.rs:

pub fn doubled(value: u32) -> Option<u32> { value.checked_mul(2) }

crates/demo-cli/Cargo.toml:

[package]
name = "demo-cli"
version.workspace = true
edition.workspace = true

[dependencies]
calc-core.workspace = true

crates/demo-cli/src/main.rs:

fn main() {
    assert_eq!(calc_core::doubled(21), Some(42));
    assert_eq!(calc_core::doubled(u32::MAX), None);
    println!("42");
}
cargo run -p demo-cli
cargo check --workspace
cargo test --workspace
cargo metadata --no-deps --format-version 1

Here, checked_mul explicitly handles overflow, avoiding the difference in default arithmetic checks between release and debug modes from leaking into the business interface.

Observing Member Selection with Real Library Tests

Append the following test to calc-core/src/lib.rs. The boundaries explicitly use u32::MAX / 2 to verify the maximum doubleable value and the immediately following overflow value, without relying on the default overflow check switch.

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

    #[test]
    fn boundary() {
        assert_eq!(doubled(0), Some(0));
        assert_eq!(doubled(u32::MAX / 2), Some(u32::MAX - 1));
        assert_eq!(doubled(u32::MAX / 2 + 1), None);
    }
}

When running cargo test at the workspace root, default-members selects only demo-cli. calc-core will be compiled as a dependency, but its library unit tests will not run as a result. Use cargo test -p calc-core or cargo test --workspace to see the boundary test execute. This explains why "the application build depends on the core library" and "the core library's tests are covered" cannot substitute for each other.

A dependency built; did its unit tests run?

The dependency relationship stays unchanged; commands select different packages’ test targets. Default testing uses calc-core through the CLI without executing calc-core’s #[test] functions. Switch to -p calc-core or --workspace to see that coverage change.

Preparing the visual
A dependency built; did its unit tests run?

With default-members=[demo-cli], default tests select the CLI; building calc-core as a dependency does not run its library unit tests.

Inheritance is an Explicit Choice, Not Automatic Root Configuration Override

Members must explicitly select respective inheritance items via workspace=true. The path in workspace.dependencies is resolved relative to the workspace root; a path written directly in a member's dependencies is resolved relative to the member's manifest. When version requirements and path coexist, the local package must also satisfy the requirements, and release semantics involve registry versions.

Members can add features when inheriting dependencies; features enabled in the root cannot be arbitrarily removed by members. Joint declarations reduce duplicate configuration but cannot prevent other transitive dependencies from introducing another incompatible version. Workspaces

Root profiles and patch configurations affect the workspace build; expecting profiles placed in member files to take effect independently usually results in an ignored warning. Lints, package metadata, etc., should also be explicitly selected according to the corresponding inheritance syntax.

Lint Inheritance Also Requires Member Integration

Add to the root manifest:

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

Each member that needs to comply with this constraint should add:

[lints]
workspace = true

This is suitable for jointly forbidding unsafe in pure business crates. It does not impose a global policy on dependency source code: external dependencies still have their own compilation contexts, and native libraries are not proven safe by this lint. If a dedicated FFI member indeed bears the unsafe boundary, it should be given explicit checking methods and configuration, rather than mistakenly assuming the root table will unconditionally override all members. Workspace Inherited Configuration

Shared Lock File Does Not Mean Only One Copy of All Dependencies

Cargo.lock can simultaneously record foo 1.x and foo 2.x. Types from different package versions have different identities; even if named struct fields are similar, they may not be interchangeable. cargo tree -d can help discover these situations, and cargo tree -i can continue to find the sources of references.

The target directory is only a shared artifact location. Differences in target architecture, profile, feature sets, host build dependencies, and compilation options can all produce different compilation units. You cannot deduce that all dependencies are compiled only once from the concept of a "shared cache."

Command Scope Must Be Clearly Stated

When not specifying -p at the virtual workspace root, default-members controls the default member selection; --workspace explicitly selects all members. For specific packages, use -p; if a package has multiple binaries, use --bin.

The test targets, doc tests, and regular builds for cargo test --workspace are not exactly the same configuration. Release checks should also be performed on the specific packages prepared for release; a successful workspace build does not mean that each package packaged independently contains the correct files and resolvable dependencies.

Root directory cargo testdefault-members: demo-clicargo test --workspaceMembers: demo-cli + calc-corecalc-core built as library dependencyDoes not run the boundary unit test within itcalc-core's test targets are also builtRuns the boundary unit testSelect package first, then target; dependency compilation does not equal dependency testingThe diagram uses the two-member configuration from this page; -p, --lib, --bin, --test control different selection levels respectively.
Command (at workspace root)Actively Selects WhatCannot Infer What From This
cargo checkDefault member demo-cliAll targets of other members have been checked
cargo test -p calc-core --libCore library unit test targetCLI, integration tests, and doc tests have run
cargo test --workspaceDefault test sets of each memberEvery feature and target platform has passed
cargo check --workspace --all-targetsMembers' lib, bin, test, example, and benchmark targetsDoc tests have executed or binaries have finished linking
cargo test --workspace --exclude demo-cliExcludes CLI when selecting membersCode depended on by other packages will never be built

Here, Cargo targets (lib/bin/test, etc.) and the platform triple specified by --target are two different dimensions. When troubleshooting "why is this package still being compiled," first check if it appears as a dependency; when troubleshooting "why is this test missing," then check if the package and test target were selected. Test Target Selection

patch is Adding Candidate Packages to a Source

Assume the codec package in the registry needs a local fix; the root manifest can write:

[patch.crates-io]
codec = { path = "vendor/codec" }

This requires vendor/codec to truly contain a package named codec whose version satisfies the dependency constraints. patch does not unconditionally override all versions with the same name; the lock file and candidate version selection may cause it not to be adopted. Check Cargo prompts, cargo tree, and the lock file, and update the resolution for that package if necessary.

It is also not the case that "all dependency requirements must be exactly the same" to use it: multiple compatible ranges can resolve to the same version, and incompatible versions may continue to coexist. Replacing another major version requires providing suitable candidates separately, rather than just changing the version number in the manifest without maintaining the API.

Patches declared by the library itself do not automatically become the root configuration for downstream applications. If it is only for local verification of the fix, record the source of use and removal conditions; if publishing, ensure that downstream parties can obtain the required version from the official dependency source. Overriding Dependencies

Reduce Build Coupling with Boundaries

The core library should expose stable types and clear errors; the CLI is responsible for arguments and output; do not bring the network runtime and build generator into all members just to share a constant. Public dependency re-exports, feature unions, and build.rs outputs all expand coupling and should be evaluated together when modifying interfaces.

Responsibilities for generated code and native builds are seen in Cargo.toml, and verification of conditional branches is seen in cfg.

Independent Packaging Exposes Dependencies Invisible Within the Workspace

First, practice local packaging with calc-core without external dependencies:

cargo package -p calc-core --list
cargo package -p calc-core

The first command checks which files will be included; the second generates the .crate and performs unpacking build verification by default. When checking the list, focus on templates, header files, and configuration examples required for runtime or build, rather than just looking at whether src exists. If build.rs depends on adjacent directories outside the workspace, the build within the repository may succeed, but after independent packaging, those inputs will be missing.

When packaging, Cargo normalizes the manifest, processes inheritance fields, and removes root configurations like workspace and patch. For demo-cli, the path of calc-core only resolves the local development location; version describes the registry dependency; formal distribution must ensure that the required version is available at the corresponding source. Placing two packages in the same repository does not allow the released CLI to automatically access that relative path. cargo package Processing Steps

If these members are only for internal joint building, you can set publish=false in the respective packages to prevent accidental publishing; this does not affect normal local path dependencies. If publishing separately, determine compatible versions around the core library's public API and update the requirements of the consumers. Unified inheritance of version is just unifying the filled value; it does not automatically analyze semantic compatibility or arrange release order.

When Onboarding New Members, Check Along the Actual Usage Chain

After adding a service package, first confirm whether workspace_members and workspace_default_members in cargo metadata match expectations; then check whether its normal dependencies on the core library, test-only dependencies, and build dependencies are placed in the correct tables. Finally, run an end-to-end use case from the user's actual entry point, for example, converting CLI arguments to business values, calling doubled, and converting None to a understandable error.

Directory splitting only creates value corresponding to interface boundaries. The core library returning Option is the complete contract for this small example; if you need to distinguish illegal input, overflow, and resource errors in the future, you should upgrade to explicit error types and let each entry point be responsible for display. Avoid having the core layer directly print or exit the process, otherwise, web services and tests, even if they reuse the function, cannot freely decide on error response methods.