Skip to content

Latest commit

 

History

35 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Standard Rust Project Layout

A reference layout for Rust projects, and a working workspace that demonstrates it. cargo build --workspace succeeds; cargo xtask ci passes.

If you are about to lay out your first Rust project, read Part 1 and then stop. Part 2 is for when you have more than one crate.

Inspired by golang-standards/project-layout.

Contents


Read this first

Rust already has an official project layout. Cargo defines it, enforces it through target auto-discovery, and every Rust developer already expects it.

It is not advice. src/lib.rs is the library target because Cargo looks there; move it and there is no library. Put integration tests in test/ instead of tests/ and cargo test will find nothing — and report success.

That makes a "standard layout" document for Rust a much smaller job than it first appears. Most of the territory is already settled, and a competing directory scheme does not produce a debatable convention, it produces a broken build. So the useful thing such a document can do is state plainly what is already decided, and then be honest that everything else is opinion.

What is genuinely open is what Cargo has no view on: how to organise a workspace, where documentation and deployment configuration live, how to configure tooling. That is where writing a convention down earns its keep.

Hence two halves, deliberately separated:

Part 1 What Cargo defines. Quoted from the Cargo Book. Non-negotiable. Deviating breaks tooling, not taste.
Part 2 What Cargo leaves open. Workspace organisation, docs, CI, deployment, tooling config. Recommendations, with reasons — argue with them.

Where an opinionated source disagrees with the Cargo Book, the Cargo Book wins, and this document says so.


Part 1 — What Cargo defines

Everything in this section is fixed. Not by convention, by the build system.

The canonical package layout

Quoted verbatim from the Cargo Book:

.
├── Cargo.lock
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   └── bin/
│       ├── named-executable.rs
│       ├── another-executable.rs
│       └── multi-file-executable/
│           ├── main.rs
│           └── some_module.rs
├── benches/
│   ├── large-input.rs
│   └── multi-file-bench/
│       ├── main.rs
│       └── bench_module.rs
├── examples/
│   ├── simple.rs
│   └── multi-file-example/
│       ├── main.rs
│       └── ex_module.rs
└── tests/
    ├── some-integration-tests.rs
    └── multi-file-test/
        ├── main.rs
        └── test_module.rs
  • Cargo.toml and Cargo.lock are stored in the root of your package (package root).
  • Source code goes in the src directory.
  • The default library file is src/lib.rs.
  • The default executable file is src/main.rs.
    • Other executables can be placed in src/bin/.
  • Benchmarks go in the benches directory.
  • Examples go in the examples directory.
  • Integration tests go in the tests directory.

And for targets that outgrow one file:

If a binary, example, bench, or integration test consists of multiple source files, place a main.rs file along with the extra modules within a subdirectory of the src/bin, examples, benches, or tests directory. The name of the executable will be the directory name.

That is the entire mandatory layout. It is short, and — unlike most things called a project layout — it is mandatory rather than suggested.

A single-crate project should look exactly like that and nothing more. No crates/, no workspace, no xtask. Everything in Part 2 is what you reach for when a project outgrows this, and most projects never do.

Target auto-discovery

The directory names above are not decoration — Cargo scans for them.

Target Discovered at Target name
library src/lib.rs package name, dashes → underscores
default binary src/main.rs package name, dashes kept
extra binaries src/bin/*.rs, src/bin/*/main.rs file stem, or directory name
examples examples/*.rs, examples/*/main.rs file stem, or directory name
integration tests tests/*.rs, tests/*/main.rs file stem, or directory name
benchmarks benches/*.rs, benches/*/main.rs file stem, or directory name

For [auto discovered] targets, it defaults to the directory or file name.

— Cargo Targets

This is the mechanical reason a custom directory scheme is not merely unconventional in Rust. Rename tests/ to test/ and cargo test runs nothing and reports success.

Auto-discovery can be switched off per target type with autolib, autobins, autoexamples, autotests and autobenches, and individual targets can be declared explicitly with a path:

[[bin]]
name = "app"          # the binary users type
path = "src/main.rs"  # required: Cargo only infers this when name == package name

Both are escape hatches for real needs — a binary whose name differs from its package, a generated target — not a licence to reorganise. See crates/app-cli/Cargo.toml for the one case in this repository.

Naming: kebab-case targets, snake_case modules

Two different rules, and mixing them up is the most common naming mistake.

Modules and crates are snake_case. The rules originate in RFC 430 and are restated, as current official guidance, in the Rust Style Guide:

Item Convention
Crates snake_case (but prefer a single word)
Modules snake_case
Types, traits, enum variants UpperCamelCase
Struct fields snake_case
Functions, methods, local variables snake_case
Macros snake_case
Statics and constants SCREAMING_SNAKE_CASE
Type parameters concise UpperCamelCase, usually T
Lifetimes short and lowercase, 'a

Acronyms count as one word: Uuid, not UUID; is_xid_start, not is_XID_start.

And one rule that is easy to get wrong, straight from the Style Guide: when the name you want is a reserved word, "either use a raw identifier (r#crate) or use a trailing underscore (crate_). Don't misspell the word (krate)."

Target names are kebab-case. Package names on crates.io are conventionally hyphenated (app-core, serde-json… ) and so are the file names in tests/, examples/, benches/ and src/bin/, because the file name is the target name:

tests/order-lifecycle.rs   →  cargo test --test order-lifecycle
examples/simple.rs         →  cargo run --example simple
src/bin/app-admin.rs       →  cargo run --bin app-admin

These files are crate roots, not modules, which is why the module rule does not apply to them. Cargo replaces hyphens with underscores when the package name becomes a library's crate name: package app-core is use app_core::….

One more from the API guidelines: "Crate names should not use -rs or -rust as a suffix or prefix. Every crate is Rust! It serves no purpose to remind users of this constantly."

Module layout inside src/

Cargo owns the directory names; the file layout inside src/ is the module system's business, and there the language gives you two forms.

Prefer foo.rs + foo/ over foo/mod.rs. From the edition guide:

In Rust 2018 the restriction that a module with submodules must be named mod.rs is lifted. foo.rs can just be foo.rs, and the submodule is still foo/bar.rs. This eliminates the special name, and if you have a bunch of files open in your editor, you can clearly see their names, instead of having a bunch of tabs named mod.rs.

src/                          src/
├── lib.rs                    ├── lib.rs
├── domain.rs        ✅        └── domain/           ❌ (legacy)
└── domain/                       ├── mod.rs
    ├── customer.rs               ├── customer.rs
    └── order.rs                  └── order.rs

Both still compile. The tab-title argument is the whole argument, and it is enough. Mixing the two forms in one project is the only genuinely bad option.

Use lib.rs as a façade. Declare modules privately, re-export the public API:

// crates/app-core/src/lib.rs
mod config;   // private
mod domain;   // private
mod error;    // private

pub use crate::config::Config;
pub use crate::domain::{Customer, CustomerId, Order, OrderId, OrderLine, OrderStatus};
pub use crate::error::{Error, Result};

Callers write app_core::Order. Nobody writes — or can write — app_core::domain::order::Order. The internal module tree is therefore not part of the public API, and splitting domain::order into three modules next month is a refactor rather than a breaking change.

This is the single highest-leverage layout decision in the whole document, and it costs six lines.

Unit tests and integration tests are different things

They are not two styles for the same job. They see different code and break for different reasons, and a project wants both.

Unit test Integration test
Lives in #[cfg(test)] mod tests, same file as the code tests/*.rs
Compiled as part of the crate a separate crate
Can see private fields and private functions only what lib.rs re-exports
Answers "is the implementation right?" "is the API usable?"
Breaks when internals change the public API changes

From the Book:

You'll put unit tests in the src directory in each file with the code that they're testing. The convention is to create a module named tests in each file to contain the test functions and to annotate the module with cfg(test).

and:

Each file in the tests directory is a separate crate, so we need to bring our library into each test crate's scope. […] They use your library in the same way any other code would, which means they can only call functions that are part of your library's public API.

This repository makes the contrast concrete on purpose. Compare the unit tests at the bottom of crates/app-core/src/domain/order.rs, which assert on the private status and lines fields, with crates/app-core/tests/order-lifecycle.rs, which contains those same assertions commented out and marked DOES NOT COMPILE.

There is no mirrored test/ tree. Rust does not have one and does not want one. Tests either sit beside the code or sit at the API boundary; there is no third place for them.

Two practical notes:

  • Shared helpers for integration tests go in tests/common/mod.rs, not tests/common.rs. Files in subdirectories of tests/ are not compiled as separate crates, so tests/common.rs would run as its own empty test suite and appear in the output.
  • cargo test --all-targets does not run doctests. This surprises everyone once. Run cargo test --doc as a second command — see xtask/src/main.rs and .github/workflows/ci.yaml, which both do.

Privacy is a language feature

Rust has no internal/ directory, needs none, and would be worse for having one.

mod domain;                    // invisible outside the crate
pub(crate) fn helper() {}      // this crate only
pub(super) fn parent_only() {} // parent module only
pub fn api() {}                // actually public

The compiler enforces every one of those, per item, wherever the item happens to sit on disk. A directory that means "private" is a convention by comparison: it needs a linter and a reviewer to hold, it cannot express "visible to my parent module but no further", and it makes the file layout carry information the language already carries better.

The private-module-plus-pub use façade above goes further still. It gives the same encapsulation and a better public API, because the internal path never appears in a caller's use statement at all.

If you want a boundary stronger than a module — one the compiler will refuse to let anyone cross even inside the same project — the tool for that is a separate crate, not a directory. Cargo also forbids cyclic crate dependencies, so a crate boundary is the only way to make "A may use B, but B may never use A" structurally true.

Commit Cargo.lock

Commit it. For binaries and for libraries. cargo new does this by default, and the reasons in the Cargo FAQ apply to both:

Deterministic builds help with

  • Running git bisect to find the root cause of a bug
  • Ensuring CI only fails due to new commits and not external factors
  • Reducing confusion when contributors see different behavior as compared to other contributors or CI

The old advice — binaries commit it, libraries do not — came from a real observation and drew the wrong conclusion. The observation is that Cargo.lock "does not affect the consumers of your package, only Cargo.toml does that"; a library's lockfile is ignored by everyone downstream. The wrong conclusion is that it is therefore useless. It is not useless to you: it makes your own CI reproducible, which is where you spend your time.

What you lose by committing it is coverage of newer dependency versions. Get that back with a scheduled job that runs cargo update first — "Verifying Latest Dependencies" in the Cargo Book — rather than by leaving every run of every CI job non-reproducible. Run CI with --locked so a stale lockfile fails loudly instead of being silently regenerated.

The lockfile is also what makes an MSRV job meaningful. With resolver = "3" Cargo picks dependency versions compatible with your rust-version when it writes the lockfile, so cargo check --locked on the old toolchain tests the versions your users will actually get.


Part 2 — What Cargo leaves open

Everything below is a recommendation, not a rule. Cargo has no opinion here, so the opinions are mine and the sources are cited. Disagree where you have a reason; the reason is the part that matters.

Most of this section only applies once a project has more than one crate. If yours has one, Part 1 was the whole document.

One workspace, crates as flat siblings

Two or more related crates means one workspace. From the Microsoft Rust guidelines:

  • M-CARGO-WORKSPACE — "Common settings come from the workspace Cargo.toml."
  • M-CRATES-IN-WORKSPACE — "The workspace lists and versions all crates."
  • M-CRATES-FLAT-FOLDER — "All crates are siblings in one folder."
✅  crates/app-core/            ❌  crates/app/core/
    crates/app-cli/                crates/app-cli/src/macros/
    crates/app-macros/             app-core/crates/helper/

Flat. One directory, one level, every crate a sibling. Nesting a crate inside another crate — and especially inside its src/ — is never acceptable: Cargo will not find it, cargo build will not build it, and no reader expects it. Add one level of grouping (crates/server/, crates/client/) only past roughly one to two dozen crates, where the flat list genuinely stops being readable.

Relationships live in names, not paths. app, app-core, app-macros sort together and read as a family. That is the entire mechanism, and it is enough, because there is no relationship between them that the build system understands anyway.

Use a virtual manifest at the root. A Cargo.toml with [workspace] and no [package]:

Alternatively, a Cargo.toml file can be created with a [workspace] section but without a [package] section. This is called a virtual manifest.

— Cargo Workspaces

The alternative — promoting one crate to the repository root — privileges it in the directory layout and starts an argument every time a crate is added. A virtual manifest has no privileged position to argue about.

resolver must be set explicitly in a virtual workspace, because there is no package.edition to infer it from. Use resolver = "3": it is MSRV-aware and will not lock you to a dependency version that needs a newer compiler than you claim to support.

Inherit everything from the workspace

Three tables in the root manifest, one line per crate to opt in.

# root Cargo.toml
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.98.1"
license = "MIT OR Apache-2.0"
repository = "https://github.com/example/rust-project-layout"

[workspace.dependencies]
app-core = { path = "crates/app-core", version = "0.1.0" }   # both, always
anyhow = "1.0.104"
serde = { version = "1.0.229", features = ["derive"] }
# crates/app-cli/Cargo.toml
[package]
name = "app-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true

[lints]
workspace = true

[dependencies]
app-core.workspace = true    # not { path = "../app-core" }
anyhow.workspace = true

Sibling crates get path and version. M-CRATES-IN-WORKSPACE: instead of sibling.path = "../sibling", intra-workspace dependencies resolve via sibling.workspace = true with the canonical version declared centrally. path is what a local build uses; version is what a published crate records for its consumers. Omit version and the crate cannot be published — a mistake that surfaces months later, at release time, and never as a build failure.

A new crate's manifest should be almost entirely inheritance. Anything it states for itself is a claim that it differs from the rest of the workspace, and that claim should be true and visible in review.

Adding a crate is three edits: the directory, [workspace] members, and [workspace.dependencies]. Forgetting the third is the common mistake.

Lints belong in [workspace.lints]

Not scattered as #![deny(...)] across crate roots, where they apply to one crate, drift out of sync with the others, and are invisible from the place people look.

[workspace.lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
unreachable_pub = "warn"

[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "warn"
expect_used = "warn"
todo = "warn"

The priority = -1 is load-bearing. From the manifest reference: "lower (particularly negative) numbers have lower priority, being overridden by higher numbers". Groups go below the individual lints so that a specific lint can override a group it belongs to. Without it, clippy::pedantic and clippy::unwrap_used fight and Cargo reports an ambiguity error.

forbid rather than deny for unsafe_code: forbid cannot be lifted by a local #[allow], so a crate that genuinely needs unsafe has to override it in its own manifest — visibly, in review.

unwrap_used and expect_used are warn, not deny, on purpose. They are correct in tests, build scripts, and main. Allow them narrowly and say why:

#[cfg(test)]
mod tests {
    // Test code: a panic *is* the failure report.
    #![allow(clippy::unwrap_used)]

Keep clippy.toml for thresholds only — it tunes lints, it cannot enable them. Which lints are on is a Cargo concern, and belongs where every crate inherits it.

Edition and MSRV

Latest edition, always. M-LATEST-EDITION: new crates set edition to the latest stable release, currently 2024 at minimum. Older editions provide no downstream compatibility advantage — a 2015-edition crate can depend on a 2024-edition crate without friction, and vice versa. An old edition on a new crate buys nothing and costs you the modern syntax.

Set an MSRV on day one, and keep it behind stable. M-MSRV: libraries declare a minimum supported Rust version at creation and update it as new compiler features become necessary, staying a few versions behind current release.

[workspace.package]
edition = "2024"
rust-version = "1.98.1"   # current stable

This repository tracks current stable in its own example, so the value does not rot between releases. A crate with downstream users should trail stable by a few releases instead, as M-MSRV advises.

Bumping the MSRV is a minor version bump, not a major one. This surprises people. The reasoning in M-MSRV is that ecosystem projects already depend on reasonably modern compilers through their transitive dependencies, so treating every MSRV bump as a breaking change produces major-version churn that helps nobody. Note it in the changelog, because it is the most common reason a downstream build breaks after an upgrade.

Declare it in exactly one place. Here, CI reads it back out of Cargo.toml rather than restating it — see the msrv job in .github/workflows/ci.yaml. Clippy reads it from there too, which is why clippy.toml has no msrv key.

Same rule for the toolchain: rust-toolchain.toml pins the channel, and the CI workflow does not mention a version at all. Pinning it twice is how the two drift apart.

Errors: thiserror for libraries, anyhow for binaries

Not a matter of taste. It follows from who the caller is.

Libraries use thiserror. Callers need to match on failures to decide what to do, so the error has to be a real type with real variants. thiserror generates the Display and Error impls and then disappears — it does not show up in your public API.

Make the enum #[non_exhaustive]. Adding a variant to a public enum is normally a breaking change; #[non_exhaustive] forces external callers to write a catch-all arm up front, which makes future variants a minor bump. Error enums grow by nature, so this is nearly always the right trade. See crates/app-core/src/error.rs.

Binaries use anyhow. Nothing downstream will match on the error; the only consumer is a human reading stderr. That makes attaching context worth far more than enumerating variants. fn main() -> anyhow::Result<()> gets you the printed error chain and a non-zero exit code for free. See crates/app-cli/src/cli.rs.

unwrap() in library code is the anti-pattern, and the reason clippy::unwrap_used is on in this workspace. It is a promise that this can never fail, made to someone who cannot check it and will be the one to see the panic. If you can prove it, write the proof in a comment next to the allow. If you cannot, return a Result.

When not to split into crates

A single-crate project does not need a crates/ directory. src/ at the repository root is the correct layout, and this repository would be smaller and better if it had one crate rather than three.

Legitimate reasons to add one:

  1. Proc macros. Not a choice: a proc-macro = true crate is compiled for the host, loaded into rustc, and may export nothing else. This is the one split the language forces. See crates/app-macros/.
  2. Separate publishing. A different release cadence, or a subset you want on crates.io without the rest.
  3. A genuinely different dependency set. Keeping a web framework or a database driver out of a crate that does not need it.
  4. Compile-time isolation. A stable core stops being rebuilt every time the volatile parts change.
  5. A boundary you want the compiler to enforce absolutely, including acyclicity — Cargo forbids crate cycles, modules do not.

"It feels tidier" is not one. A crate boundary costs a manifest, two entries in workspace tables, a public API you now have to keep stable, and a compile unit. Modules are free, and pub(crate) covers most of what people reach for a crate to express.

Automation: xtask, not make

Put repository automation in a Rust crate and alias it:

# .cargo/config.toml
[alias]
xtask = "run --package xtask --"

cargo xtask ci then expands to cargo run --package xtask -- ci. That is the entire cargo-xtask mechanism — "a way to extend stock, stable cargo with custom commands (xtasks), written in Rust".

Why it beats a Makefile:

  • Cross-platform. It "can more easily be cross platform, as it doesn't use the shell". No sh-isms, no parallel .ps1 that drifts.
  • Type checked. A typo in a Makefile recipe is found by whoever hits it. A typo here does not compile.
  • Nothing to install. A contributor with rustup has everything. make is absent on a stock Windows box; just is one more thing to install and pin.
  • CI runs the same code. When the CI job is cargo xtask ci, "works locally, fails in CI" stops being about the commands differing.

The cost is that the first cargo xtask in a clean checkout compiles the crate, which is why xtask/ has zero dependencies and should keep none. matklad's own advice: "It is advisable to minimize the compile time of xtasks." std::env::args().nth(1) is enough; adding clap here makes every contributor pay for argument parsing before their first check.

xtask runs when you ask it to — "xtasks do not integrate with Cargo lifecycle". Compile-time code generation is build.rs, a different mechanism with a different cost.

There is a Makefile in this repository, and it contains no automation. make ci runs cargo xtask ci; every recipe in it is one line of delegation. That is not a contradiction of the argument above, it is the point of it: make is muscle memory for a lot of people and costs nothing to offer, as long as the logic is not in it.

The rule that keeps it that way is written at the top of the file — if a recipe needs a second line, it has become automation, and automation goes in xtask/. A Makefile that grows recipes is the thing this section warns about; a Makefile that grows aliases is harmless. Delete it if your project has no make users; nothing depends on it, and CI does not call it.

Formatting the files rustfmt does not touch

cargo fmt covers .rs and nothing else. That leaves the Markdown, YAML, JSON and TOML in a typical repository unformatted, which is where "please fix the indentation" review comments come from.

Files Tool Config
*.rs rustfmt rustfmt.toml, via cargo fmt --check
*.md, *.yaml, *.json Prettier .prettierrc, .prettierignore
*.toml taplo, optional not used here
line endings git .gitattributes

Note the asymmetry. .rs is the one row with a real answer: rustfmt's defaults are the Rust Style Guide, an official Rust project document, which is why rustfmt.toml here is a single line and why arguing with it is arguing with the language's own style.

For every other row Rust has no convention and Cargo has no opinion, so the rest of the table is a defensible default rather than a rule. Prettier is the ecosystem-agnostic choice because most people already have it; dprint is faster and does handle TOML, at the cost of being far less widely installed.

Three things are worth copying whichever tool you pick.

Keep the hard requirement at rustup. Prettier here is optional locally and mandatory in CI: cargo xtask fmt runs it when it is on PATH and prints a skip notice when it is not, while the CI runner always has Node. A contributor is never blocked on a Node install, and the config still cannot drift — which is the only reason to have it in a repository at all.

Enforce line endings in .gitattributes, not only .editorconfig. .editorconfig asks the editor nicely; * text=auto eol=lf makes git normalise the index and check out LF on Windows. This matters more in Rust than people expect, because rustfmt does not normalise line endings — a CRLF file passes cargo fmt --check on the machine that wrote it and arrives as a whole-file diff for the next person.

Turn on the built-in diff drivers while you are there. *.rs diff=rust puts the enclosing fn or impl in every hunk header instead of a bare line number. It is one line, it is built into git, and almost nobody has it on.

One trap, since this repository hit it: .prettierrc is read as JSON or YAML, and JSON-with-comments does not error — it parses as YAML, turns the comment into a key, ignores every option after it, and exits 0. Write the file as YAML (as here, so it can explain itself) or as strict JSON with no comments at all.

Environment variables: commit .env.example, never .env

Cargo has no dotenv support, and neither does the standard library. std::env::var reads the environment the process was handed; nothing fills that environment from a file unless something in the program does it. Worth saying plainly, because a .env in a repository root looks self-evidently functional and is in fact inert.

So the file is a convention about documenting configuration, and it is worth having for that alone. Three places a value can come from:

Source Committed What it is for
.env never Real local values, credentials included. Git-ignored, on every project, without exception.
.env.example always Every variable the code reads, with a default or an empty placeholder. This file is documentation.
[env] in .cargo/config.toml always Non-secret values that only matter under Cargo — cargo run, cargo test, build scripts.

The third row is the one people do not know about. Cargo can set environment variables itself, and for a non-secret development default it is a better answer than a dotfile that nothing reads.

To make .env actually reach the process, add dotenvy — the maintained fork of the unmaintained dotenv — and call it on the first line of main, or stay outside the program with direnv or set -a; . ./.env; set +a. This workspace does neither: it reads std::env directly and takes no dotenv dependency, the same choice made for criterion and syn elsewhere here.

The ignore rule needs a negation, and the negation has a trap.

.env
.env.*
!.env.example

That works because the patterns match files. Git will not re-include a path inside an excluded directory — secrets/ followed by !secrets/example.toml matches nothing at all, and does it silently. And no ignore rule has any effect on a file that is already tracked: a .env committed once stays committed until git rm --cached, and stays in the history whatever you do next, so the fix is to rotate the values, not to delete the file.

Read the environment once, at the edge, and pass a value down. In this repository app-core::Config derives Deserialize and never mentions std::env; app-cli does the reading and hands a Config to the domain. A library that reads the environment itself has an input its caller cannot see, cannot override, and cannot vary between two tests in the same process.

That last point is now enforced by the language. std::env::set_var is unsafe in edition 2024 — it mutates state another thread may be reading — so a unit test cannot arrange an environment in-process at all. Take the lookup as an argument (impl Fn(&str) -> Option<OsString>) and the parsing becomes a pure function you can test directly; use Command::env when you want the real thing end to end, because setting variables for a child process is safe.

env! is not std::env::var. env!("APP_API_TOKEN") reads the variable at compile time and bakes the value into the binary: a build input, not runtime configuration. It makes the artefact environment-specific and the build irreproducible. Keep it for build metadata such as CARGO_PKG_VERSION.

Keep the example honest. A variable the code reads and .env.example does not list is one the next person has no way to discover, and an example that lists variables nothing reads is the same lie as an empty deploy/. Prefix every name with the binary's (APP_), so env | grep APP_ is a complete answer.

.env is not a secret store. It is a plaintext file that gets copied to a laptop, picked up by a COPY . . in a Dockerfile, and included in a backup nobody encrypted. For local development that is an acceptable trade; in production the environment comes from the platform — systemd's EnvironmentFile=, a Kubernetes secret, your cloud's secret manager. The detect-private-key hook in .pre-commit-config.yaml catches the most embarrassing version of getting this wrong.

Directories the ecosystem has no convention for

Everything below this line: Rust has no convention, and neither does this document beyond "pick one and be consistent". Cargo does not know these directories exist. Common choices, with a defensible default in bold:

Purpose Common names Notes
Long-form docs docs/, doc/, book/ cargo doc is the API reference; this is what does not fit in a /// comment.
Decision records docs/adr/, docs/decisions/, rfcs/ docs/adr/ has a template and a worked example.
Deployment deploy/, deployments/, infra/, ops/, k8s/ Dockerfiles, manifests, Terraform.
Static files assets/, resources/, static/, data/ Must stay outside src/, which Cargo compiles.
Agent notes AGENTS.md agents.md, a 2025 convention most agents now read. Nested files override per directory.
Shell scripts scripts/ Prefer xtask. See scripts/README.md.
Schemas proto/, openapi/, schemas/ Whatever the code generator expects.
Migrations migrations/ Set by your ORM (sqlx, diesel), not by you.

Delete the ones you do not use. An empty deploy/ containing only a README is worse than no deploy/: it implies a deployment story exists and sends readers looking for one. The directories in this repository are a menu, not a checklist — take crates/ and xtask/ if you need them, take nothing else unless you have something to put in it.


Anti-patterns

Things that will get flagged in review, and what to do instead.

Crates nested inside other crates. crates/app/core/, or worse crates/app-cli/src/macros/ with its own Cargo.toml. Cargo does not find them, cargo build does not build them, and no reader expects them. Flat siblings in one directory; express relationships with name prefixes. M-CRATES-FLAT-FOLDER.

path dependencies between siblings. app-core = { path = "../app-core" } works locally and then produces a crate that cannot be published, because there is no version requirement for consumers to resolve. Use app-core.workspace = true with path and version declared once in [workspace.dependencies]. M-CRATES-IN-WORKSPACE.

mod.rs everywhere. Legal, but it fills your editor with identical tab titles for no benefit. foo.rs + foo/. And whichever you pick, do not mix them.

A directory that means "private". An internal/ or pkg/ split, imported from an ecosystem whose language could not express it. Rust has pub(crate), pub(super) and private modules, all enforced by the compiler. See Privacy is a language feature.

A utils / helpers / common junk-drawer module. These names mean "I did not want to decide where this goes", and they grow monotonically because nothing ever gets removed from a module with no defining idea. Name modules after what they are about. If a function genuinely belongs nowhere, it usually belongs next to the type it operates on.

Deep hierarchies built in anticipation. src/domain/models/entities/order/ on the theory that it will be needed later. It will not, and until then every reader pays for it. Start flat — src/order.rs — and split when a file gets hard to navigate, not before. The foo.rs + foo/ form makes that split mechanical when it comes.

unwrap() in library code. A promise that this can never fail, made to someone who cannot verify it and will be the one to see the panic. thiserror for libraries, anyhow for binaries. If an unwrap is genuinely provable, allow the lint narrowly and write the proof in the comment.

A committed .env. Ignore it from the first commit, before there is anything in it worth stealing — the rule is cheap to add early and involves rotating credentials to add late, because deleting the file does not remove it from the history. Commit .env.example instead, and keep it in step with what the code actually reads.

Uncommitted Cargo.lock. Non-reproducible CI, git bisect that does not bisect, and "works on my machine" that is technically accurate. Commit it, run CI with --locked, and get fresh-dependency coverage from a scheduled cargo update job instead.

Deviating from src/ / tests/ / benches/ / examples/. This is the one that is not a matter of opinion. Those names are how Cargo finds your targets. A test/ directory does not fail loudly — cargo test reports success having run nothing at all.

Lints scattered as #![deny(...)] in crate roots. One crate's worth of coverage, invisible from the root, guaranteed to drift. [workspace.lints].

Restating the toolchain version in CI. rust-toolchain.toml already pins it and rustup already honours it. Two sources of truth is one source of truth and one source of confusion.


This repository

.
├── Cargo.toml                     # virtual manifest: [workspace], no [package]
├── Cargo.lock                     # committed, deliberately
├── rust-toolchain.toml            # channel + rustfmt, clippy — the only version pin
├── rustfmt.toml                   # one line — the defaults are the Style Guide
├── clippy.toml                    # thresholds only; lint selection is in Cargo.toml
├── deny.toml                      # cargo-deny: advisories, licences, bans, sources
├── .cargo/config.toml             # [alias] xtask = "run --package xtask --"
├── .editorconfig                  # editors: LF, indent width
├── .pre-commit-config.yaml        # optional local hooks — not used by CI
├── .gitattributes                 # git: LF enforced, plus diff=rust
├── .gitignore                     # note what is NOT ignored
├── .prettierrc                    # Markdown/YAML/JSON — what rustfmt misses
├── .prettierignore
├── .env.example                   # the committed variable list; `.env` is ignored
├── renovate.json                  # dependency updates: weekly, routine bumps grouped
├── Makefile                       # aliases for `cargo xtask` — no logic in it
├── AGENTS.md                      # the same rules, aimed at coding agents
├── README.md                      # you are here
├── CONTRIBUTING.md
├── CHANGELOG.md
├── LICENSE                        # placeholder: the MIT OR Apache-2.0 convention
├── .github/
│   └── workflows/ci.yaml          # check · msrv · deny
├── crates/                        # every crate, flat siblings
│   ├── app-core/                  #   library: domain logic, no I/O
│   │   ├── src/{lib,error,config,domain}.rs
│   │   ├── src/domain/{order,customer}.rs
│   │   ├── tests/order-lifecycle.rs
│   │   ├── examples/simple.rs
│   │   └── benches/order-total.rs
│   ├── app-cli/                   #   binary `app`: parsing and wiring only
│   │   ├── src/{main,cli}.rs
│   │   ├── src/cli/config.rs      #   the environment, read once, at the edge
│   │   └── tests/cli-args.rs
│   └── app-macros/                #   proc-macro: the split the language forces
├── xtask/                         # automation in Rust, not Make
├── docs/
│   └── adr/                       # template + one filled-in decision record
├── deploy/                        # no Rust convention — delete if unused
├── assets/                        # no Rust convention — delete if unused
└── scripts/                       # prefer xtask — delete if unused

Every directory has a README.md saying what belongs in it and, where it matters, what does not. Every non-obvious file has a comment explaining why it is where it is rather than what the code does.

Running it

$ cargo xtask ci        # fmt, clippy, tests, doctests, rustdoc — what CI runs
$ cargo xtask fmt       # format in place
$ cargo xtask lint      # clippy, --all-targets, warnings denied
$ cargo xtask test      # tests, then doctests

$ cargo run -p app-cli -- total --line widget:2:1500 --line gadget:1:999
2 lines, total EUR 39.99

$ cargo run -p app-core --example simple
$ cargo bench -p app-core --bench order-total
$ cargo doc --workspace --no-deps --open

Using it as a template

  1. Copy Cargo.toml, rust-toolchain.toml, rustfmt.toml, clippy.toml, deny.toml, .cargo/config.toml, xtask/ and the CI workflow. Take .gitattributes, .editorconfig, .prettierrc and .prettierignore too — they are four small files that end the line-ending and indentation arguments permanently.
  2. If you have one crate, stop there. Put src/ at the repository root, drop the workspace, and re-read Part 1.
  3. If you have several, keep crates/ and delete the example crates.
  4. Delete every directory you have nothing to put in.
  5. If your program reads the environment, keep .env.example and the three .gitignore lines that go with it, and replace the variables with yours. If it does not, delete both.
  6. Replace LICENSE with real licence text and fix the repository and authors fields.

References

Primary — authoritative

Opinionated, well-reasoned — the source of most of Part 2

Prior art — compared against, not copied


Licence

This repository is a reference layout, licensed MIT OR Apache-2.0 like the rest of the Rust ecosystem. See LICENSE — which is a placeholder explaining the convention, and which you should replace before publishing anything.

About

A standard project layout for Rust: what Cargo defines and enforces, and what it leaves to convention. Half reference guide, half working workspace — flat crates/, xtask automation, workspace lints, MSRV, CI and tooling config you can copy.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages