How to Understand a Rust Codebase: Crates, Traits and Features

A practical reading order for unfamiliar Rust projects, from Cargo.toml and the module tree to trait implementations, feature flags, build scripts and unsafe blocks.

By The OpenVisio Team · · 12 min read

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, and cargo metadata or cargo tree will show how they depend on each other.
  • Every crate has a root: src/lib.rs for a library, src/main.rs or src/bin/*.rs for binaries. The module tree grows from there via mod declarations.
  • Visibility (pub, pub(crate), pub(super)) tells you the intended API. Read a crate's lib.rs re-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 = ...)] and cargo tree -e features.
  • Read build.rs, procedural macros and every unsafe block 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:

RootWhat it is
src/lib.rsThe library crate
src/main.rsThe default binary
src/bin/<name>.rs or src/bin/<name>/main.rsAdditional binaries
examples/*.rsExample binaries, run with cargo run --example <name>
tests/*.rsIntegration tests, each its own crate
benches/*.rsBenchmarks
build.rsBuild 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 the Box or Arc is 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 an Ext trait imported in that file.
  • Conversion traits: From, Into, TryFrom, AsRef. The ? operator calls From::from on errors, so impl From<sqlx::Error> for Error is 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

  1. Read the root Cargo.toml, .cargo/config.toml and rust-toolchain.toml.
  2. Map workspace crates and their dependencies with cargo tree.
  3. Find every crate root and binary.
  4. Read lib.rs module declarations and re-exports.
  5. List the core traits and their implementations with rust-analyzer.
  6. Read the error type and its From conversions.
  7. List features and check which are active with cargo tree -e features.
  8. Read every build.rs and expand one macro-heavy module.
  9. List unsafe blocks and check for SAFETY comments.
  10. Run cargo check, cargo test and cargo 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.

RustCargotraitscode reading

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading