Rust Cargo Workspace Architecture & Testing
Overview
Flat virtual workspace, strict downward dependency direction, centralized versions, tests co-located by tier. Distilled from the OpenAI Codex CLI source (<workspace>\codex-main, an 80+ crate Rust 2024 workspace) plus 2026 practice (matklad's "large Rust workspaces", Cargo Book, Rust Project Primer).
Core principle: a new crate is a compile-unit + an API boundary, not a folder. Reach for a module first; promote to a crate only when the split earns its keep.
Language-agnostic parent: baseline-dev-architecture (the cross-language invariants this skill instantiates for Rust).
When to Use
- Starting or restructuring a multi-crate Rust project (CLI/agent/service).
- Deciding "new crate or just a module?", dependency direction, or error strategy.
- Setting up tests, snapshots, the runner, smoke tests, or lint/CI gates.
When NOT to use: single-crate projects — keep one Cargo.toml. TypeScript projects → use typescript-cli-architecture.
Canonical Workspace Layout (flat)
Cargo.toml # VIRTUAL manifest: [workspace] only, no [package]
Cargo.lock # committed (this is an app/CLI)
rust-toolchain.toml # pin the toolchain — everyone builds identically
clippy.toml rustfmt.toml # shared lint/format config at root
.config/nextest.toml # test-runner profiles (default + ci)
crates/ # FLAT — folder name == crate name, no prefix-stripping
types/ src/lib.rs # zero-dep data structures, DTOs, protocol types
core/ src/lib.rs # domain logic
config/ db/ auth/ ... # adapters
cli/ src/main.rs # thin binary: wires everything, logic lives in lib crates
xtask/ src/main.rs # build/automation written in Rust (no ad-hoc make/sh sprawl)
# root Cargo.toml
[workspace]
members = ["crates/*", "xtask"]
resolver = "3" # set EXPLICITLY in a virtual workspace
[workspace.package] # inherit edition/license/version once
edition = "2024"
[workspace.dependencies] # pin every shared version ONCE → no drift
serde = { version = "1", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
# internal crates referenced by PATH so every member uses them uniformly:
mycli-types = { path = "crates/types" }
mycli-core = { path = "crates/core" }
# in a member Cargo.toml: serde.workspace = true mycli-core.workspace = true
Mark internal crates publish = false; pin the toolchain in rust-toolchain.toml (e.g. [toolchain]\nchannel = "1.93.0"). Install the test tooling once: cargo install cargo-nextest cargo-insta cargo-shear.
- Flat over nested (matklad):
ls crates/ is the whole map; adding/splitting crates needs no tree surgery. Folder name must equal crate name.
- Virtual root (no
[package] at root) so cargo <cmd> doesn't need --workspace and the root stays clean.
- Dependency direction is the key decision:
types → core → {db,auth} → api → binaries. Lower layers NEVER import higher ones; Cargo refuses cycles — treat that as a design gift. types has zero logic so everything can depend on it.
- Binaries stay thin: the binary crate has BOTH
src/lib.rs (logic) and src/main.rs (a few lines that call into the lib) so integration tests and other crates can reach the logic.
- Error-type ownership: the public error enum lives in the lowest crate every public-API consumer depends on — usually
core, or types if multiple sibling crates must construct/match it. Never in the binary.
Module, Naming & Error Conventions
| Thing |
Convention |
| Module files |
foo.rs (file-per-module, 2018+ style); never both foo.rs and foo/mod.rs |
lib.rs/mod.rs |
declarations + visibility only (pub vs pub(crate)), little inline code |
| Module size |
refactor at ~500 LoC; ~800 is the hard ceiling |
| Public API errors |
thiserror enums + pub type Result<T> = std::result::Result<T, MyErr>; |
| Internal errors |
anyhow only — never leak anyhow::Result in a public signature |
| Bool/Option positional args |
annotate at call site: f(&mut cfg, /*dry_run*/ true) (codex enforces via a lint) |
| Lints |
#![deny(clippy::print_stdout, clippy::print_stderr)] in library crates |
New crate vs module — split only when ≥1 holds: different change rate, shared by multiple binaries, needs distinct feature flags, compile-parallelism win, or independently useful/testable. Don't split a <500-LoC single-consumer module, and never to break a cycle (extract a shared types/trait instead).
Testing Strategy
Rust's three built-in tiers + ecosystem add-ons:
| Tier |
Where |
Notes |
| Unit |
#[cfg(test)] mod tests inline, or a sibling *_tests.rs declared in mod.rs |
can test private items; fast; #[tokio::test] for async |
| Integration |
crate-root tests/ (each file = its own crate; public API only) |
for many tests, use ONE aggregator tests/all.rs (mod suite;) to cut compile units |
| Doc tests |
/// examples |
compiled by cargo test — keeps docs honest |
| Snapshot |
insta (assert_snapshot! / assert_debug_snapshot! / assert_json_snapshot!) |
REQUIRED for TUI/complex output; render at a FIXED terminal width, strip ANSI/color, redact volatile fields (timestamps/uuids/cursor pos); cargo insta review/accept |
| Property |
proptest! |
functions with a large input space / invariants |
- Runner:
cargo-nextest (process-per-test → isolation + up to ~3× faster). .config/nextest.toml with a ci profile: retries (flaky), slow-timeout, fail-fast=false, JUnit output, --partition across CI jobs.
- Test isolation: use
tempfile::TempDir, NOT global mutable state. #[ctor] for one-time global setup (e.g. point $HOME/app-home at a temp dir before any test). pretty_assertions::assert_eq for readable diffs.
- Shared integration helpers:
tests/common/mod.rs (the mod.rs form avoids a spurious "running 0 tests" entry).
- Clippy: allow
unwrap/expect in tests, deny in prod; use disallowed-methods for project rules.
Smoke tests (cheap gate BEFORE the full suite / expensive jobs)
cargo check --workspace — fastest "does it even compile" gate.
- Build the binary (
cargo build -p <cli>), then run --help / --version; assert exit 0.
- One end-to-end CLI happy path (a
tests/ integration test or xtask step) on the simplest real command.
CI order
cargo fmt --check → cargo clippy -D warnings → cargo check --workspace (smoke) → cargo nextest run --profile ci → cargo insta test → cargo-shear (unused deps).
Common Mistakes
| Mistake |
Fix |
| A crate per module |
Use modules; make a crate only when the split earns it (table above) |
| Each crate declares its own dep versions |
Centralize in [workspace.dependencies]; member uses dep.workspace = true |
| Resolver unset in a virtual workspace |
Set resolver = "3" explicitly |
anyhow::Result in a public API |
thiserror enum + crate Result<T> alias for public; anyhow internal only |
Fat main.rs |
Logic in lib.rs; binary just wires — so integration tests can use it |
30 separate files in tests/ (slow compiles) |
One tests/all.rs aggregator with mod suite; |
| Snapshot churn unreviewed |
cargo insta review deliberately; redact timestamps/uuids/tokens |
Tests mutate ~/.config / global state → flaky |
TempDir + #[ctor] to redirect home before tests run |
Reference
Patterns observed in <workspace>\codex-main (virtual workspace, [workspace.dependencies], layered crates, thin binary + lib, *_tests.rs + tests/all.rs aggregator + tests/common/mod.rs, #[ctor] home-dir isolation, insta snapshots, cargo nextest/just, clippy.toml disallowed-methods, /*param*/ argument-comment lint) — cross-checked against the Cargo Book, matklad's "Large Rust Workspaces", and the Rust Project Primer.
1---2name: rust-workspace-architecture3description: Use when creating, structuring, or adding a crate/module to a Rust Cargo workspace (CLI, agent, or backend) — deciding workspace layout, when to make a new crate vs a module, dependency direction, error types, where tests live, snapshot/property tests, the test runner (nextest), smoke tests, clippy/rustfmt gates, or CI. Triggers — "new crate or module?", "set up the workspace", "where do integration tests go", "insta snapshot", "cargo nextest", "smoke test the binary", "thiserror vs anyhow", "workspace.dependencies".4---56# Rust Cargo Workspace Architecture & Testing78## Overview910**Flat virtual workspace, strict downward dependency direction, centralized versions, tests co-located by tier.** Distilled from the OpenAI Codex CLI source (`<workspace>\codex-main`, an 80+ crate Rust 2024 workspace) plus 2026 practice (matklad's "large Rust workspaces", Cargo Book, Rust Project Primer).1112Core principle: *a new crate is a compile-unit + an API boundary, not a folder.* Reach for a module first; promote to a crate only when the split earns its keep.1314**Language-agnostic parent:** `baseline-dev-architecture` (the cross-language invariants this skill instantiates for Rust).1516## When to Use1718- Starting or restructuring a multi-crate Rust project (CLI/agent/service).19- Deciding "new crate or just a module?", dependency direction, or error strategy.20- Setting up tests, snapshots, the runner, smoke tests, or lint/CI gates.2122**When NOT to use:** single-crate projects — keep one `Cargo.toml`. TypeScript projects → use `typescript-cli-architecture`.2324## Canonical Workspace Layout (flat)2526```27Cargo.toml # VIRTUAL manifest: [workspace] only, no [package]28Cargo.lock # committed (this is an app/CLI)29rust-toolchain.toml # pin the toolchain — everyone builds identically30clippy.toml rustfmt.toml # shared lint/format config at root31.config/nextest.toml # test-runner profiles (default + ci)32crates/ # FLAT — folder name == crate name, no prefix-stripping33 types/ src/lib.rs # zero-dep data structures, DTOs, protocol types34 core/ src/lib.rs # domain logic35 config/ db/ auth/ ... # adapters36 cli/ src/main.rs # thin binary: wires everything, logic lives in lib crates37xtask/ src/main.rs # build/automation written in Rust (no ad-hoc make/sh sprawl)38```3940```toml41# root Cargo.toml42[workspace]43members = ["crates/*", "xtask"]44resolver = "3" # set EXPLICITLY in a virtual workspace4546[workspace.package] # inherit edition/license/version once47edition = "2024"4849[workspace.dependencies] # pin every shared version ONCE → no drift50serde = { version = "1", features = ["derive"] }51thiserror = "2"52anyhow = "1"53tokio = { version = "1", features = ["full"] }54# internal crates referenced by PATH so every member uses them uniformly:55mycli-types = { path = "crates/types" }56mycli-core = { path = "crates/core" }57# in a member Cargo.toml: serde.workspace = true mycli-core.workspace = true58```5960Mark internal crates `publish = false`; pin the toolchain in `rust-toolchain.toml` (e.g. `[toolchain]\nchannel = "1.93.0"`). Install the test tooling once: `cargo install cargo-nextest cargo-insta cargo-shear`.6162- **Flat over nested** (matklad): `ls crates/` is the whole map; adding/splitting crates needs no tree surgery. Folder name must equal crate name.63- **Virtual root** (no `[package]` at root) so `cargo <cmd>` doesn't need `--workspace` and the root stays clean.64- **Dependency direction is the key decision:** `types → core → {db,auth} → api → binaries`. Lower layers NEVER import higher ones; Cargo refuses cycles — treat that as a design gift. `types` has zero logic so everything can depend on it.65- **Binaries stay thin:** the binary crate has BOTH `src/lib.rs` (logic) and `src/main.rs` (a few lines that call into the lib) so integration tests and other crates can reach the logic.66- **Error-type ownership:** the public error enum lives in the *lowest* crate every public-API consumer depends on — usually `core`, or `types` if multiple sibling crates must construct/match it. Never in the binary.6768## Module, Naming & Error Conventions6970| Thing | Convention |71|---|---|72| Module files | `foo.rs` (file-per-module, 2018+ style); never both `foo.rs` and `foo/mod.rs` |73| `lib.rs`/`mod.rs` | declarations + visibility only (`pub` vs `pub(crate)`), little inline code |74| Module size | refactor at ~500 LoC; ~800 is the hard ceiling |75| Public API errors | `thiserror` enums + `pub type Result<T> = std::result::Result<T, MyErr>;` |76| Internal errors | `anyhow` only — never leak `anyhow::Result` in a public signature |77| Bool/Option positional args | annotate at call site: `f(&mut cfg, /*dry_run*/ true)` (codex enforces via a lint) |78| Lints | `#![deny(clippy::print_stdout, clippy::print_stderr)]` in library crates |7980**New crate vs module — split only when** ≥1 holds: different change rate, shared by multiple binaries, needs distinct feature flags, compile-parallelism win, or independently useful/testable. Don't split a <500-LoC single-consumer module, and never to break a cycle (extract a shared `types`/trait instead).8182## Testing Strategy8384Rust's three built-in tiers + ecosystem add-ons:8586| Tier | Where | Notes |87|---|---|---|88| **Unit** | `#[cfg(test)] mod tests` inline, or a sibling `*_tests.rs` declared in `mod.rs` | can test private items; fast; `#[tokio::test]` for async |89| **Integration** | crate-root `tests/` (each file = its own crate; public API only) | for many tests, use ONE aggregator `tests/all.rs` (`mod suite;`) to cut compile units |90| **Doc tests** | `///` examples | compiled by `cargo test` — keeps docs honest |91| **Snapshot** | `insta` (`assert_snapshot!` / `assert_debug_snapshot!` / `assert_json_snapshot!`) | REQUIRED for TUI/complex output; render at a FIXED terminal width, strip ANSI/color, redact volatile fields (timestamps/uuids/cursor pos); `cargo insta review`/`accept` |92| **Property** | `proptest!` | functions with a large input space / invariants |9394- **Runner: `cargo-nextest`** (process-per-test → isolation + up to ~3× faster). `.config/nextest.toml` with a `ci` profile: `retries` (flaky), `slow-timeout`, `fail-fast=false`, JUnit output, `--partition` across CI jobs.95- **Test isolation:** use `tempfile::TempDir`, NOT global mutable state. `#[ctor]` for one-time global setup (e.g. point `$HOME`/app-home at a temp dir before any test). `pretty_assertions::assert_eq` for readable diffs.96- **Shared integration helpers:** `tests/common/mod.rs` (the `mod.rs` form avoids a spurious "running 0 tests" entry).97- **Clippy:** allow `unwrap`/`expect` in tests, deny in prod; use `disallowed-methods` for project rules.9899### Smoke tests (cheap gate BEFORE the full suite / expensive jobs)1001. **`cargo check --workspace`** — fastest "does it even compile" gate.1012. **Build the binary** (`cargo build -p <cli>`), then run `--help` / `--version`; assert exit 0.1023. **One end-to-end CLI happy path** (a `tests/` integration test or `xtask` step) on the simplest real command.103104### CI order105`cargo fmt --check → cargo clippy -D warnings → cargo check --workspace (smoke) → cargo nextest run --profile ci → cargo insta test → cargo-shear (unused deps)`.106107## Common Mistakes108109| Mistake | Fix |110|---|---|111| A crate per module | Use modules; make a crate only when the split earns it (table above) |112| Each crate declares its own dep versions | Centralize in `[workspace.dependencies]`; member uses `dep.workspace = true` |113| Resolver unset in a virtual workspace | Set `resolver = "3"` explicitly |114| `anyhow::Result` in a public API | `thiserror` enum + crate `Result<T>` alias for public; `anyhow` internal only |115| Fat `main.rs` | Logic in `lib.rs`; binary just wires — so integration tests can use it |116| 30 separate files in `tests/` (slow compiles) | One `tests/all.rs` aggregator with `mod suite;` |117| Snapshot churn unreviewed | `cargo insta review` deliberately; redact timestamps/uuids/tokens |118| Tests mutate `~/.config` / global state → flaky | `TempDir` + `#[ctor]` to redirect home before tests run |119120## Reference121122Patterns observed in `<workspace>\codex-main` (virtual workspace, `[workspace.dependencies]`, layered crates, thin binary + lib, `*_tests.rs` + `tests/all.rs` aggregator + `tests/common/mod.rs`, `#[ctor]` home-dir isolation, `insta` snapshots, `cargo nextest`/`just`, `clippy.toml` disallowed-methods, `/*param*/` argument-comment lint) — cross-checked against the Cargo Book, matklad's "Large Rust Workspaces", and the Rust Project Primer.