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.
- Read this first
- Part 1 — What Cargo defines
- Part 2 — What Cargo leaves open
- One workspace, crates as flat siblings
- Inherit everything from the workspace
- Lints belong in
[workspace.lints] - Edition and MSRV
- Errors:
thiserrorfor libraries,anyhowfor binaries - When not to split into crates
- Automation:
xtask, notmake - Formatting the files rustfmt does not touch
- Environment variables: commit
.env.example, never.env - Directories the ecosystem has no convention for
- Anti-patterns
- This repository
- References
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.
Everything in this section is fixed. Not by convention, by the build system.
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.tomlandCargo.lockare stored in the root of your package (package root).- Source code goes in the
srcdirectory.- 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
benchesdirectory.- Examples go in the
examplesdirectory.- Integration tests go in the
testsdirectory.
And for targets that outgrow one file:
If a binary, example, bench, or integration test consists of multiple source files, place a
main.rsfile along with the extra modules within a subdirectory of thesrc/bin,examples,benches, ortestsdirectory. 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.
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.
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 nameBoth 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.
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."
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.rsis lifted.foo.rscan just befoo.rs, and the submodule is stillfoo/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 namedmod.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.
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
testsin each file to contain the test functions and to annotate the module withcfg(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, nottests/common.rs. Files in subdirectories oftests/are not compiled as separate crates, sotests/common.rswould run as its own empty test suite and appear in the output. cargo test --all-targetsdoes not run doctests. This surprises everyone once. Runcargo test --docas a second command — seextask/src/main.rsand.github/workflows/ci.yaml, which both do.
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 publicThe 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 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 bisectto 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.
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.
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.tomlfile can be created with a[workspace]section but without a[package]section. This is called a virtual manifest.
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.
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 = trueSibling 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.
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.
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 stableThis 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.
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.
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:
- Proc macros. Not a choice: a
proc-macro = truecrate is compiled for the host, loaded intorustc, and may export nothing else. This is the one split the language forces. Seecrates/app-macros/. - Separate publishing. A different release cadence, or a subset you want on crates.io without the rest.
- A genuinely different dependency set. Keeping a web framework or a database driver out of a crate that does not need it.
- Compile-time isolation. A stable core stops being rebuilt every time the volatile parts change.
- 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.
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.ps1that 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
rustuphas everything.makeis absent on a stock Windows box;justis 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.
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.
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.exampleThat 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.
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.
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.
.
├── 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.
$ 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- Copy
Cargo.toml,rust-toolchain.toml,rustfmt.toml,clippy.toml,deny.toml,.cargo/config.toml,xtask/and the CI workflow. Take.gitattributes,.editorconfig,.prettierrcand.prettierignoretoo — they are four small files that end the line-ending and indentation arguments permanently. - If you have one crate, stop there. Put
src/at the repository root, drop the workspace, and re-read Part 1. - If you have several, keep
crates/and delete the example crates. - Delete every directory you have nothing to put in.
- If your program reads the environment, keep
.env.exampleand the three.gitignorelines that go with it, and replace the variables with yours. If it does not, delete both. - Replace
LICENSEwith real licence text and fix therepositoryandauthorsfields.
- The Cargo Book: Package Layout — the canonical layout
- The Cargo Book: Cargo Targets — target auto-discovery
- The Cargo Book: Workspaces — virtual manifests, inheritance
- The Cargo Book: Configuration —
[env]— environment variables Cargo sets itself - The Cargo Book: The Manifest Format —
[lints],priority,rust-version - The Cargo Book: Profiles —
lto,codegen-units,strip,debug - The Cargo Book: FAQ — why have
Cargo.lockin version control - The Cargo Book: Continuous Integration — verifying latest dependencies
- The Book, ch. 7: Managing Growing Projects — packages, crates, modules
- The Book, ch. 11.3: Test Organization — unit vs integration tests
- Edition Guide: Path clarity —
foo.rs+foo/instead ofmod.rs - The Rust Style Guide — the default style rustfmt implements, plus naming conventions
- RFC 430: Finalizing naming conventions — where the casing rules originate
- Rust API Guidelines — naming, interoperability, documentation
- Microsoft: Rust Guidelines — Project — M-CARGO-WORKSPACE, M-CRATES-IN-WORKSPACE, M-CRATES-FLAT-FOLDER, M-LATEST-EDITION, M-MSRV
- matklad:
cargo-xtask— automation in Rust rather than Make thiserrorandanyhow— the library/binary error splitcargo-deny— advisories, licences, bans, sources- architecture-decision-record — ADR templates and conventions;
the source for
docs/adr/
- Rust-Trends/example_project_structure
- binnev/rust-template
- Stack Overflow: recommended directory structure for a Rust project
- Djamware: Rust project structure and best practices
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.