# Rust Workspace Architecture

> 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".

- Skill: `oimiragieo/rust-workspace-architecture` (Agent Skill)
- Install (CLI): `npx skillmds@latest add oimiragieo/rust-workspace-architecture`
- Raw SKILL.md: https://api.skillmd.com/api/skills/oimiragieo/rust-workspace-architecture/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: oimiragieo (https://skillmd.com/u/oimiragieo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/oimiragieo/rust-workspace-architecture

---


# 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)
```

```toml
# 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)
1. **`cargo check --workspace`** — fastest "does it even compile" gate.
2. **Build the binary** (`cargo build -p <cli>`), then run `--help` / `--version`; assert exit 0.
3. **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.

