| name | rust-workspace-architecture |
| description | 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". |
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)
[workspace]
members = ["crates/*", "xtask"]
resolver = "3"
[workspace.package]
edition = "2024"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
mycli-types = { path = "crates/types" }
mycli-core = { path = "crates/core" }
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.