rust · level 6

Modules & Crates

Module tree, pub, use, Cargo.toml, workspaces.

125 XP

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.lock and target/ 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 or crate::.

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 use explicitly).
  • __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
  • cargofree tier

    `cargo new`, `cargo add`, `cargo build`, `cargo run` — the entire packaging UX.

    cli
  • cargo-editfree tier

    Now built-in to cargo. `cargo add foo`, `cargo rm foo`, `cargo upgrade` for managing deps.

    cli
  • Tooling for multi-crate workspaces — version bumping, publishing, listing.

    cli
  • cargo-modulesfree tier

    Visualise a crate's module tree, including private items and orphaned modules.

    cli