Modules & Crates
Module tree, pub, use, Cargo.toml, workspaces.
Modules & Crates
Rust's project layout has more terminology than most languages, but it's all a small number of mechanical concepts:
- Package — a directory with one
Cargo.toml. The unit Cargo operates on. - Crate — a unit of compilation. Either a binary crate (compiles to an executable) or a library crate (compiles to a
.rlib/.so/.a). - Module — an in-tree namespace inside a crate.
- Workspace — a group of packages that share a
Cargo.lockandtarget/directory.
A package contains at most one library crate and any number of binary crates.
A minimal package
hello/
├── Cargo.toml
└── src/
└── main.rs
# Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"
// src/main.rs
fn main() {
println!("hi");
}
cargo run compiles the binary crate (rooted at src/main.rs) and runs it. cargo build produces target/debug/hello.
Library crates
hello/
├── Cargo.toml
└── src/
└── lib.rs
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
The library crate is rooted at src/lib.rs. Public items there form the package's API to other crates.
A package can have both at once — src/lib.rs plus src/main.rs — and the binary can use the library:
// src/main.rs
use hello::add;
fn main() {
println!("{}", add(2, 3));
}
Modules — the in-crate tree
Inside a crate, modules form a tree rooted at lib.rs or main.rs. Two ways to declare a module:
// src/lib.rs
mod math; // looks for src/math.rs OR src/math/mod.rs
pub mod parsing; // same, but the module is publicly visible
mod inline { // inline module — body right here
pub fn pi() -> f64 { 3.14159 }
}
A submodule can have its own submodules:
src/
├── lib.rs ← declares `mod parsing;`
└── parsing/
├── mod.rs ← declares `mod tokens; mod ast;`
├── tokens.rs
└── ast.rs
Or the equivalent file layout:
src/
├── lib.rs
├── parsing.rs ← declares `mod tokens; mod ast;`
└── parsing/
├── tokens.rs
└── ast.rs
Both work — pick one and be consistent. The "module file alongside the directory" pattern (the second one) is the modern preference; the older mod.rs pattern still appears in legacy code.
pub — visibility
Items are private to their module by default. To make them visible outside, prefix with pub:
// src/parsing/tokens.rs
pub fn tokenize(input: &str) -> Vec<Token> { /* ... */ }
fn helper() { /* ... */ } // private — only callable within tokens.rs
pub struct Token {
pub kind: TokenKind, // field is publicly readable
pos: usize, // field is private
}
Granular visibility:
pub fn x() {} // public to any code that can name this module
pub(crate) fn x() {} // public within this crate, hidden to consumers
pub(super) fn x() {} // public to the parent module only
pub(in path) fn x() {} // public only within the named module
pub(crate) is the workhorse for "internal API exposed across modules but not part of the published interface".
use — bringing names into scope
Long paths get tedious. use creates local aliases:
use std::collections::HashMap;
use crate::parsing::ast::Expr;
use std::io::{self, Read, Write}; // io itself + Read + Write
use crate::very::deeply::nested::Thing as MyThing;
use only affects name resolution in the current module. It doesn't affect which code is compiled. The path forms:
crate::...— absolute from the current crate's root.super::...— relative to the parent module.self::...— relative to the current module (rarely needed).bareword::...— relative to an external crate orcrate::.
Cargo.toml — the manifest
[package]
name = "myapp"
version = "0.1.0"
edition = "2024"
authors = ["you <you@example.com>"]
license = "MIT"
description = "Concise summary."
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
[dev-dependencies]
proptest = "1"
[build-dependencies]
cc = "1"
[features]
default = []
extra-validation = []
[[bin]]
name = "myapp-cli"
path = "src/bin/cli.rs"
[lib]
name = "myapp"
path = "src/lib.rs"
The crucial sections:
| Section | Purpose |
|---|---|
[package] |
Name, version, edition, metadata |
[dependencies] |
Runtime deps used by lib/bin builds |
[dev-dependencies] |
Test/example/bench-only deps |
[build-dependencies] |
Used by build.rs only |
[features] |
Optional compile-time toggles |
[[bin]] |
Override binary entry point/name |
[lib] |
Override library entry point/name |
[workspace] |
Declare a workspace (root-only) |
Workspaces — multiple crates, one repo
For non-trivial projects, one package isn't enough. Workspaces let you put related crates side-by-side, share a target/, share a Cargo.lock, and depend on sibling crates with path = "...".
my-project/
├── Cargo.toml ← workspace root
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
├── cli/
│ ├── Cargo.toml
│ └── src/main.rs
└── server/
├── Cargo.toml
└── src/main.rs
# Cargo.toml (workspace root)
[workspace]
resolver = "2"
members = ["core", "cli", "server"]
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
# cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core" }
serde.workspace = true # inherits version + features from root
cargo build at the root builds all members. cargo build -p cli builds just the cli crate. The shared Cargo.lock ensures every member uses the same version of every transitive dep — no diamond dependency problems.
Re-exports — flatten the public surface
Internal modules can be deep, but the public API doesn't have to be:
// src/lib.rs
mod parser;
mod ast;
mod codegen;
pub use parser::Parser; // re-export from parser module
pub use ast::{Expr, Stmt};
pub use codegen::compile;
Now consumers write use mycrate::Parser instead of use mycrate::parser::Parser. The internal layout is free to change without breaking callers.
cargo recipes worth memorising
| Command | What it does |
|---|---|
cargo new foo |
Create a new binary package |
cargo new --lib foo |
Create a new library package |
cargo add serde --features derive |
Add a dep to Cargo.toml |
cargo build / cargo build --release |
Compile |
cargo run -- arg1 arg2 |
Compile + run binary; args after -- go to the program |
cargo test |
Run tests (unit + integration + doc) |
cargo check |
Type-check without code generation — fastest feedback loop |
cargo doc --open |
Build docs and open in browser |
cargo clippy |
Run the lint suite |
cargo fmt |
Format with rustfmt |
cargo update |
Update Cargo.lock to latest compatible deps |
cargo tree |
Print the dependency tree |
The trifecta cargo check && cargo clippy && cargo test is the standard pre-commit incantation. CI usually runs the same.
What you don't have
Rust packaging notably lacks:
- Implicit re-exports across modules (you write
pub useexplicitly). __init__.py-style auto-loading.- Conflicting versions of the same dep at the same point in the tree (Cargo unifies semver-compatible versions; semver-incompatible versions can coexist as separate compilation units).
- Runtime
require/import(everything is resolved at compile time).
The trade-off: more typing up front, but the dependency tree is exactly what you can see in cargo tree. No mystery imports, no monkey-patching across packages.
Tools in the wild
4 tools- clicargofree tier
`cargo new`, `cargo add`, `cargo build`, `cargo run` — the entire packaging UX.
- clicargo-editfree tier
Now built-in to cargo. `cargo add foo`, `cargo rm foo`, `cargo upgrade` for managing deps.
- clicargo-workspacesfree tier
Tooling for multi-crate workspaces — version bumping, publishing, listing.
- clicargo-modulesfree tier
Visualise a crate's module tree, including private items and orphaned modules.