In this guide
Rust codebases reward a specific reading order. The compiler enforces a lot, so once you understand how a project is assembled, its behavior is usually explicit. But the assembly itself has several layers that other languages do not: a workspace of crates, a module tree declared by mod statements rather than directories alone, trait implementations that can live far from both the trait and the type, feature flags that change what compiles, macros that generate code you cannot see, and build scripts that run before any of it.
This guide walks through those layers in order. It uses cargo commands and rust-analyzer heavily, because in Rust the tooling can answer structural questions precisely, and guessing from file names is less reliable than it looks.
Key takeaways
- Start with the root
Cargo.toml. If it has a[workspace]section, the repo is several crates, andcargo metadataorcargo treewill show how they depend on each other. - Every crate has a root:
src/lib.rsfor a library,src/main.rsorsrc/bin/*.rsfor binaries. The module tree grows from there viamoddeclarations. - Visibility (
pub,pub(crate),pub(super)) tells you the intended API. Read a crate'slib.rsre-exports to see what it offers. - Traits are the main abstraction. Use rust-analyzer's "Go to Implementations" to find impls, which can live in any module of the crate that owns the trait or the type.
- Feature flags change the code that compiles. Check
[features],#[cfg(feature = ...)]andcargo tree -e features. - Read
build.rs, procedural macros and everyunsafeblock deliberately. They are where Rust's guarantees have edges.
Step 1: Read the workspace
Open the root Cargo.toml. A workspace looks like this:
[workspace]
resolver = "2"
members = ["crates/*", "xtask"]
[workspace.package]
edition = "2021"
rust-version = "1.80"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
thiserror = "1"
members lists the crates; globs like crates/* are common. [workspace.dependencies] centralizes versions, and member crates opt in with tokio = { workspace = true }. An xtask member is a common convention for project automation written in Rust, invoked as cargo xtask <command> via an alias in .cargo/config.toml. Read that config file too; it can set aliases, target-specific flags and environment variables.
Check rust-toolchain.toml for a pinned compiler version or nightly channel. Nightly means unstable features may be in use, which you will see as #![feature(...)] at crate roots.
To see how the workspace crates depend on each other:
cargo tree --workspace --depth 1
cargo metadata --no-deps --format-version 1 | jq '.packages[] | {name, deps: [.dependencies[].name]}'
Sort the crates into layers. A typical shape is a core or domain crate with few dependencies, adapter crates for storage and networking, and one or more binary crates that assemble everything. If the workspace has a crate named *-macros or *-derive, it is a procedural macro crate.
Step 2: Find the crate roots
Each crate has one or more roots, and everything else in the crate is reachable only through mod declarations from those roots:
| Root | What it is |
|---|---|
src/lib.rs | The library crate |
src/main.rs | The default binary |
src/bin/<name>.rs or src/bin/<name>/main.rs | Additional binaries |
examples/*.rs | Example binaries, run with cargo run --example <name> |
tests/*.rs | Integration tests, each its own crate |
benches/*.rs | Benchmarks |
build.rs | Build script, compiled and run before the crate |
Cargo.toml can override these with [lib] and [[bin]] sections, so check it before assuming. For binaries, main is where you start reading; for async programs, look for #[tokio::main] or #[async_std::main] above it.
Step 3: Walk the module tree
Rust does not include a file just because it exists. A file is part of the crate only if some module declares it with mod name;. The compiler then looks for name.rs or name/mod.rs next to the declaring file (for nested modules, inside a directory named after the parent). Both styles are common, and older code favors mod.rs.
So the module tree is in the code, not the directory listing. Start at lib.rs and read the mod lines:
// src/lib.rs
pub mod config;
pub mod error;
mod storage;
pub mod api;
pub use crate::api::{Client, Request};
pub use crate::error::{Error, Result};
This tells you four things: which modules exist, which are public (pub mod), which are private implementation details (mod storage), and what the crate's intended API surface is (the pub use re-exports). Re-exports are the crate's front door. Read them first when you want to know what a library offers.
Visibility modifiers carry design intent:
pub: visible to anyone who can see the parent module.pub(crate): visible anywhere in this crate, not to other crates.pub(super): visible to the parent module.- No modifier: private to the module and its children.
Generate the docs to browse the public API with types and trait impls linked:
cargo doc --workspace --no-deps --open
cargo doc --document-private-items --open # include private items
Step 4: Understand the traits
Traits are how Rust codebases express abstraction, so understanding a project largely means understanding its traits. For each important trait, find where it is defined, what its methods are, and which types implement it.
Implementations can live in the crate that defines the trait or the crate that defines the type (the orphan rule), and within that crate, in any module. Searching works as a rough first pass:
grep -rn "impl.* Storage for " --include=*.rs .
But rust-analyzer's "Go to Implementations" is precise and catches generic and blanket impls like impl<T: Display> Render for T, which a text search will misread. Also check for derived traits: #[derive(Debug, Clone, Serialize, Deserialize)] generates impls you will not find by searching for impl.
Common patterns to recognize:
- Trait objects (
Box<dyn Storage>,Arc<dyn Storage + Send + Sync>): runtime polymorphism, chosen when the concrete type is picked at startup. Read where theBoxorArcis constructed to learn which implementation is used. - Generics with bounds (
fn run<S: Storage>(s: S)): compile-time polymorphism. The concrete type is decided at the call site. - Extension traits: a trait implemented for a foreign type to add methods, often named
FooExt. If a method seems to appear from nowhere on a standard type, look for anExttrait imported in that file. - Conversion traits:
From,Into,TryFrom,AsRef. The?operator callsFrom::fromon errors, soimpl From<sqlx::Error> for Erroris how database errors become your error type.
Error handling is worth a dedicated read. Look for the crate's error type, usually in error.rs, often built with thiserror for libraries or using anyhow in applications. The #[from] attributes on a thiserror enum tell you which lower-level errors can surface where.
Step 5: Decode feature flags
Cargo features are compile-time switches. They let a crate include or exclude code and optional dependencies:
[features]
default = ["postgres"]
postgres = ["dep:sqlx", "sqlx/postgres"]
sqlite = ["dep:sqlx", "sqlx/sqlite"]
metrics = ["dep:prometheus"]
In the code, features appear as #[cfg(feature = "metrics")] on items or cfg!(feature = "metrics") in expressions. Find them all:
grep -rn 'cfg(.*feature' --include=*.rs . | sort | uniq -c | sort -rn | head
Features are additive and unified across the dependency graph: if any crate in the build enables a feature on a dependency, it is enabled for everyone. That is why a binary sometimes compiles code you thought was off. To see which features are actually active and why:
cargo tree -e features -i sqlx
Also watch for other cfg conditions: #[cfg(test)] modules, #[cfg(target_os = "linux")] platform code, and #[cfg(debug_assertions)]. They mean the code you are reading may not be the code that runs in your configuration. rust-analyzer grays out inactive code, which helps.
Step 6: Read build.rs and the macros
Build scripts. A build.rs at a crate root runs before the crate compiles. It can generate code (commonly from protobuf with prost-build or tonic-build), compile and link C libraries via the cc crate, probe the system, and set cfg flags with cargo:rustc-cfg=... (or cargo::rustc-cfg=... in newer Cargo). Generated code usually lands in OUT_DIR and is pulled in with:
include!(concat!(env!("OUT_DIR"), "/proto.rs"));
If you cannot find where a type is defined, it may be generated by a build script. The generated files are under target/debug/build/<crate>-<hash>/out/.
Macros. Declarative macros (macro_rules!) are defined in the codebase and can be read directly. Procedural macros (derives like #[derive(MyTrait)], attributes like #[instrument] or #[async_trait], function-like macros) come from proc-macro crates and generate code at compile time. To see what a macro actually produces, install cargo-expand:
cargo install cargo-expand
cargo expand --lib storage::postgres # expand one module
Expanding is the fastest way to understand a derive-heavy module or a framework that relies on attribute macros, such as an Axum handler stack or a serde-heavy data layer.
Step 7: Audit unsafe and concurrency
Find every unsafe block and function:
grep -rn "unsafe " --include=*.rs . | grep -v "^./target"
Each unsafe block should have a // SAFETY: comment explaining which invariants make it sound. Blocks without one deserve careful reading, and so does any safe function wrapping unsafe code, because the safe wrapper is promising callers that the invariant always holds. If the crate root has #![forbid(unsafe_code)], you can skip this step for that crate.
For async code, identify the runtime (almost always Tokio) and find where tasks are spawned with tokio::spawn or held in a JoinSet. As in any concurrent system, ask what stops each task and where its errors go. Shared state shows up as Arc<Mutex<T>>, Arc<RwLock<T>>, or channels like tokio::sync::mpsc; reading the types tells you the concurrency model before you read the logic.
Step 8: Build and test to confirm
cargo check --workspace --all-targets
cargo test --workspace
cargo clippy --workspace --all-targets
cargo build --workspace --all-features # surfaces feature-gated code
cargo check is fast and confirms that the workspace builds in your environment. Running with --all-features and with --no-default-features on a crate shows which code paths depend on configuration. Clippy warnings are often a good list of the codebase's rough edges.
A first-day checklist
- Read the root
Cargo.toml,.cargo/config.tomlandrust-toolchain.toml. - Map workspace crates and their dependencies with
cargo tree. - Find every crate root and binary.
- Read
lib.rsmodule declarations and re-exports. - List the core traits and their implementations with rust-analyzer.
- Read the error type and its
Fromconversions. - List features and check which are active with
cargo tree -e features. - Read every
build.rsand expand one macro-heavy module. - List
unsafeblocks and check forSAFETYcomments. - Run
cargo check,cargo testandcargo clippy.
If the workspace is large, the same boundary questions that apply to TypeScript and Python monorepos apply here; see navigating a TypeScript monorepo for the general ideas of package boundaries and build graphs. Codebase Knowledge by OpenVisio can index a Rust repository in your browser and show the module structure as a graph in Explore, which is a quick way to see which crates sit at the center of the workspace.

