The file that won't compile
You've written a script in Python that does exactly what you want. Now you're porting it to Rust. You start with main.rs. You add functions. The file grows to 400 lines. You decide to split the logic into utils.rs. You create the file. You add mod utils; to main.rs. The compiler rejects you. You add pub to the functions. It compiles. You celebrate.
Then you add a second binary. You try to share code between them. You copy-paste functions. You realize you're maintaining the same bug in two places. You look at your directory structure and feel like you're guessing. Is lib.rs only for libraries? Why does cargo care about src/bin/? How do modules map to files?
Rust's project structure feels rigid until you see the pattern. The rigidity is a feature. It forces you to separate the build configuration from the code, and the public API from the internal implementation. Once you internalize the rules, the structure stops fighting you and starts protecting you.
Crate versus module
Rust distinguishes between a crate and a module. A crate is the unit of compilation. It's what cargo builds. A module is a namespace inside a crate. It's how you organize code.
Think of a crate as a shipping container. The container has a label, dimensions, and a manifest. That's Cargo.toml. Inside the container, you pack boxes. Those boxes are modules. You can arrange the boxes however you want, but the container itself has a fixed shape.
The file system defines the roots. Cargo.toml tells cargo where the crate lives. By default, cargo expects code in src/. If src/main.rs exists, cargo builds a binary crate. If src/lib.rs exists, cargo builds a library crate. If both exist, cargo builds both.
This dual-root behavior is the first surprise. A binary crate can have a library part. A library crate can have a binary part. The presence of lib.rs does not mean "this project is a library." It means "this crate exposes a library interface." You can use lib.rs to hold shared logic for multiple binaries in the same project.
The minimal project
Start with cargo new. This command generates a manifest and a source directory. It sets up the defaults so you don't have to configure paths manually.
cargo new my_project
cd my_project
The output looks like this:
my_project/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml contains the package metadata and dependencies. src/main.rs contains the entry point.
// src/main.rs
/// Entry point for the binary.
fn main() {
println!("Hello from my_project");
}
Run cargo run. The compiler builds the binary and executes it. The binary name matches the package name in Cargo.toml. If you rename the package, the binary name changes.
This structure works for simple tools. It breaks down when you need to share code or add entry points.
Adding shared logic with lib.rs
You have a function that calculates a hash. You want to use it in main.rs and later in a test harness. Copying the function is a trap. You'll diverge. You'll fix a bug in one place and forget the other.
Create src/lib.rs. This file becomes the root of the library crate. You can declare modules here and expose them publicly.
// src/lib.rs
/// Utilities shared across the project.
pub mod utils;
// src/utils.rs
/// Computes a simple checksum for demonstration.
pub fn checksum(data: &[u8]) -> u64 {
data.iter().map(|b| *b as u64).sum()
}
Now main.rs can use the function. You reference the library crate by the package name.
// src/main.rs
use my_project::utils::checksum;
/// Entry point for the binary.
fn main() {
let data = b"hello";
let sum = checksum(data);
println!("Checksum: {}", sum);
}
The compiler resolves my_project to the library part of the crate. This works because lib.rs exists. The binary and library are two faces of the same crate. They share the same dependencies and the same version.
Convention aside: many beginners avoid lib.rs in binary projects because they think it's reserved for published libraries. That's a misconception. lib.rs is the home for shared logic. Use it early. It makes testing easier and keeps main.rs focused on orchestration.
Treat lib.rs as the heart of your crate. Even binaries beat better with one.
Multiple binaries
Some projects produce more than one executable. A server might have a main daemon and a CLI tool for administration. A game might have an engine binary and a level editor.
Rust supports multiple binaries through src/bin/. Any .rs file in src/bin/ becomes a separate binary. The binary name matches the file name.
mkdir -p src/bin
touch src/bin/admin.rs
// src/bin/admin.rs
use my_project::utils::checksum;
/// Admin tool for managing data.
fn main() {
let data = b"admin-secret";
println!("Admin checksum: {}", checksum(data));
}
Run cargo run --bin admin. cargo compiles the admin binary and runs it. You can also run the main binary with cargo run.
The Cargo.toml manifest lists the binaries automatically. cargo detects src/main.rs and files in src/bin/. You can override names or paths in Cargo.toml using [[bin]] sections, but the defaults usually suffice.
Convention aside: keep src/main.rs for the primary entry point. Use src/bin/ for secondary tools. If your project has only one binary, stick with src/main.rs. Adding src/bin/ just for one file adds noise.
Modules and visibility
Modules organize code into namespaces. You declare modules with mod. You control access with pub.
The module tree starts at lib.rs or main.rs. From there, you branch out.
// src/lib.rs
pub mod utils;
pub mod config;
mod internal;
pub mod makes the module visible outside the crate. mod without pub keeps it private. The internal module can be used by utils and config, but external crates cannot access it.
Inside a module, you use pub on items to expose them.
// src/config.rs
/// Configuration settings.
pub struct Config {
pub name: String,
timeout: u64,
}
impl Config {
/// Creates a new configuration with defaults.
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
timeout: 30,
}
}
}
The Config struct is public. The name field is public. The timeout field is private. External code can read name but cannot modify timeout directly. This encapsulation prevents accidental misuse.
Convention aside: prefer pub(crate) over pub when you want to share code within the project but hide it from external users. pub(crate) restricts visibility to the current crate. It's the sweet spot for internal helpers that shouldn't leak into the public API.
// src/internal.rs
/// Helper used only within this crate.
pub(crate) fn parse_header(line: &str) -> Option<&str> {
line.strip_prefix("HEADER:")
}
The compiler enforces these boundaries. If you try to access a private item from outside its module, you get an error.
error[E0603]: module `internal` is private
This error code E0603 means the module is not public. The fix is to add pub or pub(crate) where appropriate.
Pitfalls and compiler errors
Structure mistakes show up as compiler errors. The errors are precise. They point to the missing declaration or the wrong visibility.
Forgetting pub on a struct is common. You define the struct in a module, use it in main.rs, and the compiler rejects you.
error[E0603]: struct `Config` is private
The struct exists, but it's not exposed. Add pub to the struct definition.
Circular dependencies are impossible in Rust. The module tree is a directed acyclic graph. You cannot have mod a that depends on mod b which depends on mod a. The compiler catches this early.
File system mismatches cause E0583. You declare mod foo; but there's no foo.rs or foo/mod.rs.
error[E0583]: file not found for module `foo`
Check the filename. Rust 2018 and later use foo.rs for modules. The old mod.rs style is deprecated. If you have a directory foo/, the module file is foo/mod.rs only if you want the module to be a directory. Otherwise, use foo.rs.
Convention aside: cargo fmt formats every file the same way. Don't argue style; argue logic. Run cargo fmt before committing. It removes noise and keeps the codebase consistent.
Trust the borrow checker. It usually has a point. Structure errors are often visibility errors. Fix the visibility, and the code compiles.
Decision matrix
Use cargo new when you start a fresh project; it sets up the manifest and directory structure so you don't have to guess.
Use src/lib.rs when you have logic shared between multiple binaries or you want to expose a public API for other crates to depend on.
Use src/bin/ when your project produces multiple executables, like a main server and a helper CLI tool.
Use mod declarations in lib.rs or main.rs to build a tree of modules that matches your logical separation, not just your file count.
Reach for pub(crate) when you want to share code within the project but hide it from external users.
Reach for pub only on the interface that external crates need to use. Keep implementation details private.
Start simple. Add complexity only when the compiler forces you to.