Small programs fit in one main.rs. Real ones do not. Once a project has a few thousand lines, you need to split it into parts, decide which parts are public and which are internal details, reuse other people's code, and perhaps share your own. Rust has two layers for this. The module system organises code inside a crate: mod declares modules, pub controls what is visible, and use brings names into scope. Cargo, Rust's build tool and package manager, organises code between crates: it downloads dependencies, resolves versions, builds everything in the right order, and publishes crates to crates.io.
This lesson covers packages, crates and modules and how they differ; mod, pub and use with paths; how modules map to files; re-exports; struct field privacy; Cargo.toml, dependencies, semantic versioning and Cargo.lock; features; workspaces; publishing basics; and the Cargo commands you will use every day. Interviewers commonly ask what the difference between a crate and a module is, what "private by default" means in Rust, and whether Cargo.lock belongs in version control; you will be able to answer all three, and to lay out a multi-file project without fighting the compiler.
Packages, crates and modules
Three words are easy to mix up, so define them first.
- A crate is the unit the Rust compiler compiles at one time. It is a tree of modules starting from one root file, called the crate root. A crate is either a binary crate, which has a
mainfunction and compiles to an executable, or a library crate, which has nomainand provides code for other crates to use. When Rust developers say "crate" on its own, they usually mean a library. - A package is what Cargo builds: a folder with a
Cargo.tomlthat describes one or more crates. A package can contain at most one library crate and any number of binary crates. - A module is a named section of code inside a crate, used to group related items and to control their visibility. Modules nest like folders.
package "shop" (folder with Cargo.toml)
|
+-- library crate "shop" root: src/lib.rs
| +-- module billing
| +-- module inventory
| +-- module stock
|
+-- binary crate "shop" root: src/main.rs
(uses the library as `shop::...`)
Cargo finds crate roots by convention, so you rarely configure them:
| File | Becomes |
|---|---|
src/main.rs | A binary crate with the package's name |
src/lib.rs | The library crate, with the package's name |
src/bin/other.rs | An extra binary crate called other |
tests/*.rs | Integration tests, each its own crate (lesson 18) |
examples/*.rs | Example programs, run with cargo run --example name |
cargo new shop creates a binary package and cargo new --lib shop a library package. A package can have both roots at once, which is the most common layout for applications of any size: the logic lives in the library, where it can be tested and reused, and main.rs stays thin.
Compared with other languages: a crate is roughly a Java JAR or a Python distribution package; a module is roughly a Java package or a Python module; and Cargo plays the roles of Maven or Gradle plus the compiler driver, or of pip plus a build system.
Modules inside one file
The mod keyword declares a module. Its contents can be written inline, in braces:
mod kitchen {
pub mod orders {
pub fn place(dish: &str) -> u32 {
let ticket = super::next_ticket(dish);
println!("order #{ticket}: {dish}");
ticket
}
}
fn next_ticket(dish: &str) -> u32 {
100 + dish.len() as u32
}
}
fn main() {
let t = crate::kitchen::orders::place("masala dosa");
let t2 = kitchen::orders::place("filter coffee");
println!("tickets {t} and {t2}");
}
Output:
order #111: masala dosa
order #113: filter coffee
tickets 111 and 113
The module tree for this file is:
crate (the crate root: this file)
+-- kitchen (private module)
| +-- orders (pub module)
| | +-- place (pub fn)
| +-- next_ticket (private fn)
+-- main
Paths
To use an item, you name it with a path, with segments separated by ::, like a file path:
- An absolute path starts from the crate root with the keyword
crate:crate::kitchen::orders::place. - A relative path starts from the current module:
kitchen::orders::placeworks inmainbecausekitchenis a sibling ofmainin the crate root. supermeans the parent module, like..in a file system.placelives inkitchen::orders, sosuper::next_ticketrefers tokitchen::next_ticket.selfmeans the current module, which is mostly used inusedeclarations.
"masala dosa" has 11 bytes and "filter coffee" has 13, so the tickets are 111 and 113.
Visibility: private by default
Every item in Rust (function, struct, enum, module, constant, trait) is private by default. A private item is visible in the module where it is defined and in that module's descendants, but not to its parent or siblings. pub makes an item visible to anything that can see its parent module.
That explains the example above:
maincan reachkitchenbecause they are siblings in the same module (the crate root); being private does not hide an item from its own module.kitchen::ordersandplacearepub, somaincan callkitchen::orders::place.next_ticketis private tokitchen, butplacecan call it, becauseplaceis inside a descendant ofkitchen. Children can see their ancestors' private items.
Reading the compiler error: E0603, private item
This does not compile:
mod kitchen {
pub mod orders {
pub fn place(dish: &str) {
println!("order: {dish}");
}
}
fn next_ticket() -> u32 {
7
}
}
fn main() {
kitchen::orders::place("idli");
println!("{}", kitchen::next_ticket());
}
error[E0603]: function `next_ticket` is private
--> private_err.rs:15:29
|
15 | println!("{}", kitchen::next_ticket());
| ^^^^^^^^^^^ private function
|
note: the function `next_ticket` is defined here
--> private_err.rs:8:5
|
8 | fn next_ticket() -> u32 {
| ^^^^^^^^^^^^^^^^^^^^^^^
main is outside kitchen, so it cannot see the private next_ticket. The note points at the definition. The fix is either to make the function pub (if outside code really should call it) or, more often, to leave it private and use the public API that the module provides. Privacy errors are the compiler enforcing the boundary you designed.
Finer control: pub(crate) and pub(super)
pub on an item in a library makes it part of the crate's public API, visible to other crates. Often you want something in between:
mod payments {
pub(crate) fn fee(amount: u64) -> u64 {
amount * 2 / 100 + helpers::rounding()
}
mod helpers {
pub(super) fn rounding() -> u64 {
1
}
}
}
fn main() {
println!("fee on 5000 = {}", payments::fee(5_000));
}
Output:
fee on 5000 = 101
5,000 × 2 / 100 = 100, plus the rounding of 1, gives 101.
| Visibility | Visible to |
|---|---|
| (nothing) | The current module and its descendants |
pub(super) | The parent module (and its descendants) |
pub(crate) | Anywhere in the current crate, but not other crates |
pub(in crate::some::path) | The named ancestor module |
pub | Anyone who can reach the parent module, including other crates |
pub(crate) is common in libraries: it lets modules inside the crate share helpers without making them part of the API that users depend on and that you must keep stable.
Struct fields and enum variants
Making a struct pub does not make its fields public. Each field has its own visibility, and fields are private by default. Enum variants, on the other hand, are public whenever the enum is, because an enum whose variants you cannot name is useless.
mod bank {
pub struct Account {
pub owner: String,
balance: u64,
}
impl Account {
pub fn open(owner: &str) -> Self {
Self {
owner: owner.to_string(),
balance: 0,
}
}
pub fn deposit(&mut self, amount: u64) {
self.balance += amount;
}
pub fn balance(&self) -> u64 {
self.balance
}
}
pub enum Kind {
Savings,
Current,
}
pub fn describe(kind: Kind) -> &'static str {
match kind {
Kind::Savings => "savings",
Kind::Current => "current",
}
}
}
use bank::{Account, Kind};
fn main() {
let mut acc = Account::open("Neha");
acc.deposit(2_500);
acc.owner.push_str(" S.");
println!("{} has {}", acc.owner, acc.balance());
println!(
"{} and {}",
bank::describe(Kind::Savings),
bank::describe(Kind::Current)
);
}
Output:
Neha S. has 2500
savings and current
owner is a public field, so main can read and even change it. balance is private, so the only ways to touch it are the public methods deposit and balance. This is how Rust does encapsulation: the module that defines the struct decides which changes are allowed.
Reading the compiler errors: E0616 and E0451, private fields
Reading or writing a private field from outside the module does not compile:
mod bank {
pub struct Account {
pub owner: String,
balance: u64,
}
impl Account {
pub fn open(owner: &str) -> Self {
Self {
owner: owner.to_string(),
balance: 0,
}
}
}
}
fn main() {
let mut acc = bank::Account::open("Neha");
acc.balance = 1_000_000;
println!("{} is rich now", acc.owner);
}
error[E0616]: field `balance` of struct `Account` is private
--> field_err.rs:19:9
|
19 | acc.balance = 1_000_000;
| ^^^^^^^ private field
A struct with any private field also cannot be built with a struct literal from outside its module, because you would have to set the private field. This does not compile:
mod bank {
pub struct Account {
pub owner: String,
balance: u64,
}
impl Account {
pub fn balance(&self) -> u64 {
self.balance
}
}
}
fn main() {
let acc = bank::Account {
owner: String::from("Neha"),
balance: 1_000_000,
};
println!("{} has {}", acc.owner, acc.balance());
}
error[E0451]: field `balance` of struct `Account` is private
--> literal_err.rs:17:9
|
15 | let acc = bank::Account {
| ------------- in this type
16 | owner: String::from("Neha"),
17 | balance: 1_000_000,
| ^^^^^^^ private field
This is a feature, not a nuisance. If the only way to create an Account is Account::open, then every Account in the program starts with a balance of 0, and every later change goes through deposit. Whatever rules those functions enforce (a balance never negative, a discount never above 100%) hold for every value of the type. The cart in lesson 6 promised exactly this.
Interview tip
Asked how Rust achieves encapsulation without classes, explain that privacy is per module, not per type: fields and functions are private to the module that defines them unless marked pub. A struct with private fields can only be built and changed through the functions its module exports, so the module controls its invariants. Mention pub(crate) for crate-internal sharing.
use: bringing names into scope
Writing full paths everywhere is tiring. A use declaration creates a shortcut, like an import in Java or Python:
use std::collections::HashMap;
use std::io::Result as IoResult;
use std::io::{self, Write};
mod geometry {
pub mod shapes {
pub fn square(side: f64) -> f64 {
side * side
}
}
pub use self::shapes::square;
}
use geometry::square;
fn main() -> IoResult<()> {
let mut areas: HashMap<&str, f64> = HashMap::new();
areas.insert("tile", square(3.0));
let mut out = io::stdout().lock();
writeln!(out, "areas = {areas:?}")?;
writeln!(out, "full path: {}", geometry::shapes::square(1.5))?;
writeln!(out, "re-export: {}", geometry::square(2.0))?;
Ok(())
}
Output:
areas = {"tile": 9.0}
full path: 2.25
re-export: 4
What each line does:
use std::collections::HashMap;brings one item into scope.stdis the standard library crate, which is always available.use std::io::Result as IoResult;renames an item while importing it, which avoids clashing with the prelude'sResult.use std::io::{self, Write};imports several items from one path.selfimports the moduleioitself, so you can writeio::stdout().Writeis a trait, and traits must be in scope for their methods (herewriteln!onout) to be callable.pub use self::shapes::square;insidegeometryis a re-export: it makessquareavailable asgeometry::square, in addition to its real pathgeometry::shapes::square.use geometry::square;at the top level then brings the re-exported name into the crate root.
main returns IoResult<()> so that ? can be used on writeln!, which can fail if standard output is closed. Locking stdout once and writing to the lock avoids taking the lock for every line.
Conventions for use
The Rust community follows a few conventions that make code easier to read:
| What you import | Convention | Example |
|---|---|---|
| Functions | Import the parent module, call module::function | use std::fs; then fs::read_to_string(...) |
| Structs, enums, traits | Import the item itself | use std::collections::HashMap; |
| Two items with the same name | Import the parents, or rename with as | fmt::Result and io::Result |
Everything (* glob) | Avoid, except preludes and use super::*; in test modules | use std::collections::*; hides where names come from |
Writing fs::read_to_string rather than a bare read_to_string tells the reader at the call site where the function lives.
use only creates a shortcut; it does not include or load anything. Unlike C's #include, it copies no code. And it applies only to the module where it is written: a use at the top of main.rs does not affect other modules, even ones declared in the same file.
Splitting modules into files
Inline modules are fine for small examples, but real crates put each module in its own file. The rule: mod name; with a semicolon instead of a body tells the compiler "the contents of this module are in another file", and the compiler looks for it in a fixed place.
| Declaration | In file | Compiler looks for |
|---|---|---|
mod billing; | src/lib.rs (crate root) | src/billing.rs or src/billing/mod.rs |
mod inventory; | src/lib.rs | src/inventory.rs or src/inventory/mod.rs |
mod stock; | src/inventory.rs | src/inventory/stock.rs or src/inventory/stock/mod.rs |
The mod.rs form is the older style. Since the 2018 edition, the recommended style is inventory.rs plus a folder inventory/ for its submodules, which avoids having many files all called mod.rs open in your editor. Use one style consistently.
Here is the shop package from the diagram above, built for real:
shop/
+-- Cargo.toml
+-- Cargo.lock
+-- src/
+-- lib.rs crate root of the library
+-- main.rs crate root of the binary
+-- billing.rs mod billing
+-- inventory.rs mod inventory
+-- inventory/
+-- stock.rs mod inventory::stock
src/lib.rs declares the top-level modules and re-exports the main type:
//! A tiny shop: an inventory of items and invoices that bill them.
pub mod billing;
pub mod inventory;
pub use billing::Invoice;
The line starting with //! is an inner doc comment, documentation for the crate itself, shown by cargo doc.
src/inventory.rs declares its own submodule, stock, and defines the Item type:
pub mod stock;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Item {
pub name: String,
pub price_paise: u64,
}
pub fn catalogue() -> Vec<Item> {
vec![
Item {
name: String::from("pen"),
price_paise: 1_000,
},
Item {
name: String::from("notebook"),
price_paise: 4_500,
},
]
}
pub fn find(name: &str) -> Option<Item> {
catalogue().into_iter().find(|item| item.name == name)
}
src/inventory/stock.rs:
pub fn available(name: &str) -> u32 {
match name {
"pen" => 40,
"notebook" => 3,
_ => 0,
}
}
src/billing.rs uses items from the other module through absolute paths starting with crate:
use crate::inventory::Item;
use crate::inventory::stock;
pub struct Invoice {
lines: Vec<(String, u64)>,
}
impl Invoice {
pub fn new() -> Self {
Self { lines: Vec::new() }
}
pub fn add(&mut self, item: &Item, quantity: u32) -> Result<(), String> {
let in_stock = stock::available(&item.name);
if quantity > in_stock {
return Err(format!("only {in_stock} {} in stock", item.name));
}
self.lines
.push((item.name.clone(), item.price_paise * u64::from(quantity)));
Ok(())
}
pub fn total_paise(&self) -> u64 {
let subtotal: u64 = self.lines.iter().map(|(_, amount)| amount).sum();
subtotal + tax(subtotal)
}
}
impl Default for Invoice {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "gst")]
fn tax(subtotal: u64) -> u64 {
subtotal * 18 / 100
}
#[cfg(not(feature = "gst"))]
fn tax(_subtotal: u64) -> u64 {
0
}
src/main.rs is a separate crate, so it reaches the library through the package name, shop, exactly as an outside user would:
use shop::Invoice;
use shop::inventory;
fn main() {
let mut invoice = Invoice::new();
for (name, quantity) in [("pen", 10), ("notebook", 5), ("notebook", 2)] {
let Some(item) = inventory::find(name) else {
println!("unknown item {name}");
continue;
};
match invoice.add(&item, quantity) {
Ok(()) => println!("added {quantity} x {name}"),
Err(e) => println!("could not add {quantity} x {name}: {e}"),
}
}
let total = invoice.total_paise();
println!("total: Rs {}.{:02}", total / 100, total % 100);
let json = serde_json::to_string(&inventory::catalogue()).expect("items serialise");
println!("catalogue: {json}");
}
Running it:
added 10 x pen
could not add 5 x notebook: only 3 notebook in stock
added 2 x notebook
total: Rs 190.00
catalogue: [{"name":"pen","price_paise":1000},{"name":"notebook","price_paise":4500}]
Things to notice:
- The file
billing.rsdoes not start withmod billing { ... }. The file is the module body, and its name and place in the tree come from themod billing;line inlib.rs. - Each module is declared exactly once, by its parent.
billing.rsdoes not declaremod inventory;again; it refers to the existing module withuse crate::inventory::.... Declaring it twice would compile the file twice as two different modules. main.rswritesuse shop::Invoice;, which works because of the re-export inlib.rs. Without it, users would have to writeshop::billing::Invoice. Re-exports let you organise files for your convenience and still offer users a short, stable API.ItemderivesSerializefrom theserdecrate, andmainprints the catalogue as JSON withserde_json. Those are external dependencies, covered next.- Ten pens at Rs 10 and two notebooks at Rs 45 make Rs 190. The order for five notebooks is refused because only 3 are in stock.
Reading the compiler error: E0583, file not found for module
If lib.rs declares a module whose file does not exist, for example pub mod shipping;, the build fails:
Compiling shop v0.1.0 (/home/you/shop)
error[E0583]: file not found for module `shipping`
--> src/lib.rs:8:1
|
8 | pub mod shipping;
| ^^^^^^^^^^^^^^^^^
|
= help: to create the module `shipping`, create file "src/shipping.rs" or "src/shipping/mod.rs"
= note: if there is a `mod shipping` elsewhere in the crate already, import it with `use crate::...` instead
For more information about this error, try `rustc --explain E0583`.
error: could not compile `shop` (lib) due to 1 previous error
The help names both places the compiler looked. The note covers the other common cause: someone wrote mod shipping; in a second file intending to use a module declared elsewhere. In Rust, mod declares and use imports; they are not interchangeable as import and require sometimes are in other languages.
Reading the compiler error: E0432, unresolved import
Importing a name that does not exist at that path fails with E0432, and every later use of the name fails too:
Compiling shop v0.1.0 (/home/you/shop)
error[E0432]: unresolved import `shop::billing::Bill`
--> src/main.rs:1:5
|
1 | use shop::billing::Bill;
| ^^^^^^^^^^^^^^^----
| |
| no `Bill` in `billing`
error[E0433]: cannot find type `Invoice` in this scope
--> src/main.rs:5:23
|
5 | let mut invoice = Invoice::new();
| ^^^^^^^ use of undeclared type `Invoice`
Some errors have detailed explanations: E0432, E0433.
For more information about an error, try `rustc --explain E0432`.
error: could not compile `shop` (bin "shop") due to 2 previous errors
Here main.rs wrote use shop::billing::Bill;, but the type is called Invoice. The second error, E0433, is a knock-on effect: because the import was changed, Invoice is no longer in scope. Fix the first error and the rest usually disappear. Other common causes of E0432 are a private module somewhere along the path, or using a crate that is not listed in Cargo.toml.
Cargo.toml and dependencies
Cargo.toml is the manifest: a file in TOML format (a simple key = value configuration language) that describes the package. Here is the shop manifest after adding two dependencies:
[package]
name = "shop"
version = "0.1.0"
edition = "2024"
[features]
default = []
gst = []
[dependencies]
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
| Section | Purpose |
|---|---|
[package] | Name, version, edition, and metadata such as description and license |
[dependencies] | Crates the package needs to build and run |
[dev-dependencies] | Crates needed only for tests, examples and benchmarks |
[build-dependencies] | Crates needed by a build.rs build script |
[features] | Optional, switchable parts of the package (below) |
[profile.release] and others | Compiler settings per profile, such as opt-level or panic = "abort" |
[workspace] | Makes this folder a workspace (below) |
The edition field is per crate. A crate on the 2024 edition can depend on crates written for 2015, 2018 or 2021, and everything links together; editions change syntax and defaults, not the compiled form.
Adding dependencies
cargo add edits Cargo.toml for you, picking the newest compatible version:
$ cargo add serde --features derive
Adding serde v1.0.229 to dependencies
Features:
+ derive
+ serde_derive
+ std
- alloc
- rc
- unstable
$ cargo add serde_json
Adding serde_json v1.0.154 to dependencies
Features:
+ std
- alloc
- arbitrary_precision
- float_roundtrip
- indexmap
- preserve_order
- raw_value
- unbounded_depth
The + lines are features that will be enabled and the - lines are optional features left off. The next build downloads and compiles the crates and everything they depend on:
Downloading crates ...
Downloaded itoa v1.0.18
Downloaded serde v1.0.229
Downloaded serde_core v1.0.229
Downloaded zmij v1.0.23
Downloaded serde_derive v1.0.229
Downloaded memchr v2.8.3
Downloaded serde_json v1.0.154
Compiling proc-macro2 v1.0.107
Compiling unicode-ident v1.0.26
Compiling quote v1.0.47
Compiling serde_core v1.0.229
Compiling zmij v1.0.23
Compiling serde_json v1.0.154
Compiling serde v1.0.229
Compiling memchr v2.8.3
Compiling itoa v1.0.18
Compiling syn v3.0.8
Compiling serde_derive v1.0.229
Compiling shop v0.1.0 (/home/you/shop)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.70s
Crates are downloaded from crates.io, the public registry, once, and cached in ~/.cargo. Notice that shop asked for two crates but Cargo compiled eleven before compiling shop itself. The extra ones are transitive dependencies: dependencies of dependencies. cargo tree shows the whole graph:
shop v0.1.0 (/home/you/shop)
├── serde v1.0.229
│ ├── serde_core v1.0.229
│ └── serde_derive v1.0.229 (proc-macro)
│ ├── proc-macro2 v1.0.107
│ │ └── unicode-ident v1.0.26
│ ├── quote v1.0.47
│ │ └── proc-macro2 v1.0.107 (*)
│ └── syn v3.0.8
│ ├── proc-macro2 v1.0.107 (*)
│ ├── quote v1.0.47 (*)
│ └── unicode-ident v1.0.26
└── serde_json v1.0.154
├── itoa v1.0.18
├── memchr v2.8.3
├── serde_core v1.0.229
└── zmij v1.0.23
(*) means "already shown above"; Cargo builds each crate version once even if several crates depend on it. (proc-macro) marks a crate that runs inside the compiler to generate code, which is how #[derive(Serialize)] works (lesson 19).
Version requirements and semantic versioning
Crates use semantic versioning (semver): a version MAJOR.MINOR.PATCH promises that a release which increases only PATCH fixes bugs, one that increases MINOR adds features without breaking existing code, and one that increases MAJOR may break code. For versions below 1.0, the convention shifts one place: 0.MINOR.PATCH, where a MINOR bump may break code.
In Cargo.toml, serde_json = "1.0.154" does not mean "exactly 1.0.154". It is a caret requirement, the default: any version that semver says is compatible.
| Requirement | Allows | Note |
|---|---|---|
"1.0.154" (same as "^1.0.154") | >= 1.0.154, < 2.0.0 | The default and usual choice |
"0.9.2" | >= 0.9.2, < 0.10.0 | Below 1.0, the minor version is the breaking one |
"~1.0.154" | >= 1.0.154, < 1.1.0 | Tilde: patch updates only |
"=1.0.154" | Exactly 1.0.154 | Rarely needed; makes conflicts likely |
"*" | Any version | Not allowed for crates published to crates.io |
If two crates in your graph need different compatible versions of the same crate (say ^1.0.100 and ^1.0.150), Cargo picks one version that satisfies both. If they need incompatible versions (1.x and 2.x), Cargo includes both, and they are treated as different crates.
Cargo.lock
A requirement like "1.0.154" allows many versions, so two builds on two days could pick different ones. Cargo.lock records the exact version, source and checksum of every crate in the graph that the last resolution chose. Cargo writes it automatically, and later builds use those exact versions until you change Cargo.toml or run cargo update. Part of shop's lock file:
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
The checksum lets Cargo verify that the downloaded file is exactly the one recorded. cargo update re-resolves every dependency to the newest versions the requirements allow, and cargo update -p serde_json updates just one.
Should you commit Cargo.lock to version control? For applications, always: it makes builds reproducible for everyone on the team and in CI. For libraries, Cargo's guidance changed in 2023: committing it is now the default recommendation for libraries too, and cargo new --lib no longer adds it to .gitignore. A library's lock file is ignored when someone else depends on the library (they resolve with their own lock file), so committing it affects only your own builds and CI.
Pitfall: confusing Cargo.toml and Cargo.lock
Cargo.toml says what versions you accept; Cargo.lock says what versions you got. Edit the first by hand or with cargo add; never edit the second by hand. If a build works on your machine but not in CI, compare lock files first.
Features
A feature is a named, optional part of a package that users can switch on at build time. Features let a crate offer extra functionality, or extra dependencies, without forcing them on everyone. You have already used one: serde's derive feature turns on the #[derive(Serialize)] macros, which most but not all users want.
shop declares its own feature, gst, in the [features] table of the manifest above. default = [] lists features enabled when the user asks for nothing, here none. Code is included or left out with the cfg attribute (short for configuration), as in billing.rs:
#[cfg(feature = "gst")]
fn tax(subtotal: u64) -> u64 {
subtotal * 18 / 100
}
#[cfg(not(feature = "gst"))]
fn tax(_subtotal: u64) -> u64 {
0
}
Exactly one of the two tax functions is compiled, depending on the feature. Running with the feature enabled:
$ cargo run -q --features gst
added 10 x pen
could not add 5 x notebook: only 3 notebook in stock
added 2 x notebook
total: Rs 224.20
catalogue: [{"name":"pen","price_paise":1000},{"name":"notebook","price_paise":4500}]
18% GST on Rs 190.00 is Rs 34.20, giving Rs 224.20.
Rules worth knowing:
--features a,benables features,--no-default-featuresturns off the defaults, and--all-featuresenables everything.- In a dependency,
serde = { version = "1", features = ["derive"] }enables a feature of that crate, anddefault-features = falseturns its defaults off. - An optional dependency is declared with
optional = trueand switched on by a feature that lists"dep:crate_name". - Features should be additive: turning one on should only add functionality, never remove or change it. The reason is feature unification: if two crates in your graph depend on
serde, one withderiveand one without, Cargo buildsserdeonce with the union of both feature sets. Code that breaks when a feature is unexpectedly on is a bug.
Workspaces
As a project grows, you may want several packages that are developed together: a core library, a command-line tool and a web server, say. A workspace is a set of packages that share one Cargo.lock and one target directory, so they are built together and always agree on dependency versions.
Building one: create a folder with a Cargo.toml that has a [workspace] section instead of a [package] section (a virtual manifest), then create the member packages inside it. cargo new adds new packages to the workspace's member list automatically:
$ cargo new --lib --vcs none crates/tally-core
Creating library `tally-core` package
Adding `tally-core` as member of workspace at `/home/you/tally`
$ cargo new --vcs none crates/tally-cli
Creating binary (application) `tally-cli` package
Adding `tally-cli` as member of workspace at `/home/you/tally`
(--vcs none skips creating a separate git repository for each member.) The root manifest, after tidying the list:
[workspace]
members = ["crates/tally-cli", "crates/tally-core"]
resolver = "3"
resolver = "3" selects the dependency resolver that matches the 2024 edition. A package on the 2024 edition gets it automatically, but a virtual manifest has no edition, so it is set explicitly.
The library, crates/tally-core/src/lib.rs, counts words. The /// comments are doc comments for the item below them, and the #[cfg(test)] module holds unit tests (lesson 18):
use std::collections::HashMap;
/// Counts the whitespace-separated words in `text`.
pub fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
/// Returns the `n` most frequent lowercase words, most frequent first.
pub fn top_words(text: &str, n: usize) -> Vec<(String, usize)> {
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
pairs.truncate(n);
pairs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_words() {
assert_eq!(word_count("to be or not to be"), 6);
}
#[test]
fn finds_top_words() {
let top = top_words("To be or not to be", 2);
assert_eq!(top, vec![(String::from("be"), 2), (String::from("to"), 2)]);
}
}
The binary depends on the library through a path dependency:
$ cargo add tally-core --path crates/tally-core -p tally-cli
Adding tally-core (local) to dependencies
[package]
name = "tally-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
tally-core = { version = "0.1.0", path = "../tally-core" }
crates/tally-cli/src/main.rs:
fn main() {
let text = "the cat sat on the mat and the dog sat too";
println!("{} words", tally_core::word_count(text));
for (word, count) in tally_core::top_words(text, 2) {
println!("{word}: {count}");
}
}
The package is called tally-core, with a hyphen, but code refers to it as tally_core, because hyphens are not allowed in Rust identifiers. Cargo converts them automatically.
Building from the workspace root builds every member, in dependency order:
Compiling tally-core v0.1.0 (/home/you/tally/crates/tally-core)
Compiling tally-cli v0.1.0 (/home/you/tally/crates/tally-cli)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
-p (short for --package) selects one member:
$ cargo run -p tally-cli
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/tally-cli`
11 words
the: 3
sat: 2
The sentence "the cat sat on the mat and the dog sat too" has 11 words; "the" appears 3 times and "sat" twice. cargo test at the root runs the tests of every member; both tally-core tests pass.
tally/
+-- Cargo.toml [workspace] (virtual manifest)
+-- Cargo.lock one lock file for all members
+-- target/ one build directory for all members
+-- crates/
+-- tally-core/ library package
| +-- Cargo.toml
| +-- src/lib.rs
+-- tally-cli/ binary package, depends on tally-core
+-- Cargo.toml
+-- src/main.rs
Larger workspaces also use [workspace.dependencies] in the root manifest to declare shared dependency versions once, with members writing serde = { workspace = true }, and [workspace.package] to share fields like version and edition.
Idiom: split crates for build speed and boundaries
The crate, not the module, is the unit of compilation. Splitting a large crate into several workspace members lets Cargo compile them in parallel and rebuild only the ones that changed, and it makes dependencies between parts explicit: tally-core cannot accidentally depend on tally-cli.
Publishing basics
Publishing a library to crates.io makes it available to everyone with cargo add. The steps:
- Create an account on crates.io (it signs in with GitHub), create an API token, and run
cargo loginwith it. - Fill in the metadata crates.io requires: at least
descriptionandlicense(for examplelicense = "MIT OR Apache-2.0", the common choice in the Rust ecosystem).repository,readme,keywordsandcategorieshelp people find the crate. - Check what would be uploaded with
cargo publish --dry-run, then publish withcargo publish.
A dry run of tally-core as it stands warns about the missing metadata, packages the crate, verifies that the packaged copy builds on its own, and stops before uploading:
$ cargo publish --dry-run -p tally-core
Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository
|
= note: see https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info
Packaging tally-core v0.1.0 (/home/you/tally/crates/tally-core)
Packaged 4 files, 1.9KiB (1.1KiB compressed)
Verifying tally-core v0.1.0 (/home/you/tally/crates/tally-core)
Compiling tally-core v0.1.0 (/home/you/tally/target/package/tally-core-0.1.0)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.20s
Uploading tally-core v0.1.0 (/home/you/tally/crates/tally-core)
warning: aborting upload due to dry run
Important rules: a published version is permanent. You cannot overwrite or delete it, so that nobody's build breaks. If you publish a broken version, you can yank it with cargo yank --version 0.1.0, which stops new projects from selecting it while letting existing lock files keep working. Every later release needs a higher version number, chosen according to semver. Crate names are first come, first served.
Useful Cargo commands
| Command | What it does |
|---|---|
cargo new name / cargo new --lib name | Create a binary or library package |
cargo check | Type-check without producing a binary; the fastest feedback loop |
cargo build / cargo build --release | Compile in debug or optimised mode |
cargo run [-- args] | Build and run a binary; arguments after -- go to your program |
cargo test | Build and run unit, integration and doc tests (lesson 18) |
cargo fmt | Format all code with rustfmt |
cargo clippy | Run the Clippy linter |
cargo doc --open | Build HTML documentation for your crate and its dependencies |
cargo add / cargo remove | Add or remove a dependency in Cargo.toml |
cargo update [-p name] | Re-resolve dependencies to the newest allowed versions |
cargo tree | Show the dependency graph; cargo tree -i name shows who depends on name |
cargo install name | Build and install a binary crate from crates.io into ~/.cargo/bin |
cargo publish / cargo yank | Publish a version / withdraw a version from new resolution |
cargo clean | Delete the target directory |
Idioms and pitfalls
Pitfall: mod in every file that uses a module
Programmers from Python or JavaScript often write mod utils; at the top of every file that needs utils. Each mod declaration creates a new module, so this either fails with E0583 (the compiler looks for utils.rs relative to the current module) or compiles the same file twice as two unrelated modules. Declare each module once, in its parent, and use it everywhere else.
Pitfall: everything pub
Making every field and function pub to silence privacy errors throws away encapsulation and turns every internal detail into public API that you must keep stable. Start private, and expose the smallest API that works. pub(crate) is usually enough for sharing inside a crate.
Idiom: thin main.rs, logic in lib.rs
Put the program's logic in src/lib.rs and its modules, and keep main.rs to argument parsing and error reporting. Library code can be tested from integration tests and reused by other binaries; code inside main.rs cannot be imported by anything.
Idiom: re-export a flat public API
Organise files however suits development, then use pub use in lib.rs to present the important types at the top level (shop::Invoice). You can then move code between internal modules without breaking users.
Exercises
Exercise 1: draw the module tree
For the shop package, write the full path of available, and say which modules can call tax and why.
Solution
available is shop::inventory::stock::available from outside the crate, or crate::inventory::stock::available inside the library. tax is a private function in billing, so only the billing module (and any modules nested inside it, of which there are none) can call it. main.rs and inventory cannot; that is why the binary only ever sees the total.
Exercise 2: fix the privacy errors
The field_err program sets acc.balance from main. Make it compile without making balance public, so that the final balance is 1,000,000.
Solution
Add a public method to the bank module, such as pub fn deposit(&mut self, amount: u64) { self.balance += amount; }, and a getter pub fn balance(&self) -> u64. In main, call acc.deposit(1_000_000) and print acc.balance(). This is exactly the fields example earlier in the lesson. The point is that the module, not the caller, decides how the balance may change: you could add a check that refuses deposits of 0 without touching any caller.
Exercise 3: version requirements
For each requirement, say whether version 1.4.2 of a crate satisfies it: (a) "1.2", (b) "~1.2", (c) "1.5", (d) "=1.4.0". Then: would "0.4" accept 0.5.0?
Solution
(a) Yes: "1.2" means >= 1.2.0, < 2.0.0. (b) No: "~1.2" means >= 1.2.0, < 1.3.0. (c) No: 1.4.2 is below the minimum 1.5.0. (d) No: only 1.4.0 exactly. And "0.4" means >= 0.4.0, < 0.5.0, so 0.5.0 is not accepted, because below 1.0 a minor bump counts as breaking.
Exercise 4: add a binary
Add a second binary to the shop package called restock that prints how many notebooks are available, without touching main.rs.
Solution
Create src/bin/restock.rs. Cargo discovers it automatically as a binary crate called restock, and it uses the library like main.rs does:
fn main() {
let n = shop::inventory::stock::available("notebook");
println!("notebooks available: {n}");
}
Run it with cargo run --bin restock; it prints notebooks available: 3. Once a package has several binaries, plain cargo run asks you to choose with --bin, unless default-run is set in [package].
Exercise 5: design a feature
You want tally-core to offer an optional json feature that adds a function returning the top words as JSON, using serde_json, without forcing serde_json on users who do not need it. What goes in Cargo.toml, and how is the function written?
Solution
Make the dependency optional and tie it to the feature:
[dependencies]
serde_json = { version = "1", optional = true }
[features]
json = ["dep:serde_json"]
Then put #[cfg(feature = "json")] on the function (and on any use serde_json line). Users who write tally-core = { version = "0.1", features = ["json"] } get the function and the dependency; everyone else gets neither, and their builds stay smaller and faster.
Interview questions
Q1. What is the difference between a crate, a module and a package?
A crate is the unit of compilation: a tree of modules rooted at one file, producing either a library or an executable. A module is a namespace inside a crate that groups items and controls their privacy. A package is a Cargo concept: a folder with Cargo.toml that contains at most one library crate and any number of binary crates.
Q2. What does "private by default" mean in Rust?
Every item is visible only within the module where it is defined and that module's descendants, unless marked pub (or a restricted form such as pub(crate)). Struct fields are private by default even in a pub struct, while variants of a pub enum are public. This lets each module guarantee its own invariants, because outside code can only use what it exposes.
Q3. How do modules map to files?
mod name; in a file declares a child module whose body is in name.rs next to the crate root, or in a folder named after the parent module for nested modules (src/inventory/stock.rs), or alternatively in name/mod.rs. The file does not repeat mod name { }; the file is the body. Each module is declared once by its parent, and other files refer to it with use.
Q4. What is the difference between mod and use?
mod declares a module, adding it to the crate's module tree and telling the compiler where its code is. use creates a shortcut to an existing path in the current scope; it loads nothing and copies no code. Writing mod where use is meant is a common beginner error that leads to E0583 or duplicated modules.
Q5. What are re-exports, and why use them?
pub use path::Item; makes an item available at a new path in addition to its original one. Libraries use re-exports to present a short, flat public API (mycrate::Client) while keeping a deeper internal file structure, and to move code between internal modules without breaking users.
Q6. What is the difference between Cargo.toml and Cargo.lock?
Cargo.toml is the hand-written manifest declaring the package and version requirements for dependencies, such as serde = "1.0". Cargo.lock is generated by Cargo and records the exact versions and checksums chosen for the whole dependency graph, so builds are reproducible. Commit both for applications; current guidance is to commit the lock file for libraries too, knowing it is ignored by downstream users.
Q7. How does Cargo interpret a version requirement like "1.2.3"?
As a caret requirement: any semver-compatible version, >= 1.2.3 and < 2.0.0. For pre-1.0 versions the left-most non-zero component is treated as the breaking one, so "0.2.3" means >= 0.2.3, < 0.3.0. Tilde (~), exact (=) and comparison requirements are also available.
Q8. What are Cargo features, and why should they be additive?
Features are named flags declared in [features] that conditionally compile code (#[cfg(feature = "x")]) and enable optional dependencies. Cargo unifies features across the dependency graph, building each crate once with the union of all requested features, so a feature enabled by one dependent is on for all of them. If a feature removed or changed behaviour, some other crate could break just because a third crate enabled it.
Q9. What is a Cargo workspace?
A set of packages that share one Cargo.lock and one target directory, declared by a root Cargo.toml with a [workspace] section listing the members. Members are built together with consistent dependency versions, can depend on each other with path dependencies, and can share dependency versions and metadata through [workspace.dependencies] and [workspace.package]. Commands take -p to select a member.
Q10. What happens when you publish a crate, and can you remove a version?
cargo publish packages the crate, verifies that the package builds, and uploads it to crates.io, where that version becomes permanent and cannot be overwritten or deleted. You can yank a version, which prevents new lock files from selecting it but leaves existing users unaffected. The manifest must include metadata such as a description and a license.
Key takeaways
- A package (with
Cargo.toml) holds at most one library crate and any number of binaries; a crate is a tree of modules compiled together; a module is a namespace inside a crate. - Everything is private by default;
pub,pub(crate)andpub(super)widen visibility. Struct fields are private even in apubstruct, enum variants are not. - Private fields force construction and changes through the module's functions, which is how Rust encapsulates invariants.
- Paths start with
crate,self,superor a crate name;usecreates shortcuts andpub usere-exports. mod name;loadsname.rs(orname/mod.rs); declare each module once in its parent, anduseit elsewhere.Cargo.tomlstates caret version requirements by default;Cargo.lockpins exact versions and should be committed.- Features are additive flags that compile optional code and dependencies; Cargo unifies them across the graph.
- Workspaces share a lock file and build directory across packages; published versions are permanent but can be yanked.
Next lesson
Next comes generics. Until it is published, review the Rust course overview.

