Skip to content

Modules, crates, and Cargo

Cargo is Maven and Gradle in one tool: Cargo.toml declares the package and its dependencies, a crate is the unit that the compiler builds (like a JAR), and modules inside a crate organize the code and control privacy.

Java

pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>invoice-tool</artifactId>
<version>0.1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version> <!-- one exact version -->
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.3</version>
<scope>test</scope> <!-- like [dev-dependencies] -->
</dependency>
</dependencies>
</project>

Rust

Cargo.toml
[package]
name = "cargo-demo"
version = "0.1.0"
edition = "2024" # the language edition, chosen per crate
rust-version = "1.99" # the oldest compiler that this crate supports
description = "A small invoice tool. It shows the parts of a Cargo package."
license = "MIT"
publish = false
[dependencies]
serde = { version = "1.0.229", features = ["derive"] } # "1.0.229" means ">=1.0.229, <2.0.0"
serde_json = { version = "1.0.154", optional = true } # only built when a feature asks for it
thiserror = "2.0.21"
[dev-dependencies] # only for tests and examples
serde_json = "1.0.154"
[features]
default = ["json"] # on unless the user turns them off
json = ["dep:serde_json"] # feature "json" turns on the optional crate

The manifest is shorter, and it has no group ID: crate names on crates.io are global. The examples on this site use a real package with this manifest, examples/cargo-demo/, which CI builds and tests.

Rust Java Meaning
crate a JAR, or an assembly in .NET what the compiler builds in one step: a library or a binary
package a Maven module one Cargo.toml, with one library crate and/or one or more binary crates
workspace a multi-module Maven project several packages with one shared Cargo.lock and target/ folder
crates.io Maven Central the public registry
docs.rs javadoc.io the documentation of every published crate

A package with src/main.rs builds a binary. A package with src/lib.rs builds a library. It can have both.

serde = "1.0.229" does not mean “exactly 1.0.229”. It is a caret requirement: any version from 1.0.229 up to, but not including, 2.0.0. Cargo picks the newest version that fits, and writes it into Cargo.lock. This is semantic versioning: Cargo assumes that a minor version does not break the API.

Requirement Accepts
"1.0.229" or "^1.0.229" >= 1.0.229, < 2.0.0
"0.15" >= 0.15.0, < 0.16.0 (for 0.x, the minor version is the breaking one)
"~1.2.3" >= 1.2.3, < 1.3.0
"=1.2.3" exactly 1.2.3
"*" any version. crates.io does not accept published crates with this

Cargo.lock records the exact version of every dependency, also the indirect ones. cargo new adds it to version control by default, for applications and libraries. The Cargo documentation says that whether to commit it depends on the needs of the project. For an application, commit it: it makes builds repeatable. Note that a library’s Cargo.lock does not affect the users of the library. Only its Cargo.toml does.

A feature is a named switch that turns on optional code or optional dependencies. In the manifest above, json turns on the optional serde_json dependency, and default turns json on unless the user asks for no default features. In the code, #[cfg(feature = "json")] marks the parts that need it:

mod billing; // the module's code is in `src/billing.rs`
use billing::{Invoice, Line, VAT_PER_MILLE};
fn main() {
let invoice = Invoice {
number: "2026-001".to_string(),
lines: vec![Line {
item: "Book".to_string(),
cents: 2_000,
}],
};
println!("VAT: {VAT_PER_MILLE} per mille");
println!("total with VAT: {}", invoice.total_with_vat_cents());
#[cfg(feature = "json")] // compiled only when the "json" feature is on
println!(
"{}",
billing::to_json(&invoice).expect("an invoice is always valid JSON")
);
}

Many crates put large parts behind features. tokio = { version = "1", features = ["full"] } turns on all of them. In review, ask if the code needs all the features that it turns on: each one adds compile time and code.

A module is a namespace with privacy, like a Java package. Unlike Java, a module is declared in code with mod, and the file layout follows the declarations:

File Module path Declared by
src/main.rs crate (the root) (the crate root)
src/billing.rs crate::billing mod billing; in main.rs
src/billing/tax.rs crate::billing::tax mod tax; in billing.rs

The older layout src/billing/mod.rs instead of src/billing.rs still works, and you will see it in many crates. Both mean the same.

mod tax; // private: code outside `billing` cannot use `billing::tax`
pub use tax::VAT_PER_MILLE; // a re-export: callers write `billing::VAT_PER_MILLE`
use serde::Serialize;
#[derive(Debug, Serialize)] // `Serialize` comes from the `serde` crate, with its "derive" feature
pub struct Line {
pub item: String,
pub cents: u64,
}
#[derive(Debug, Serialize)]
pub struct Invoice {
pub number: String,
pub lines: Vec<Line>,
}
impl Invoice {
pub fn total_with_vat_cents(&self) -> u64 {
let net: u64 = self.lines.iter().map(|l| l.cents).sum();
net + tax::vat_cents(net) // a private item of a child module, used by its parent
}
}
#[cfg(feature = "json")]
pub fn to_json(invoice: &Invoice) -> Result<String, serde_json::Error> {
serde_json::to_string(invoice)
}
/// The Swiss standard VAT rate, in parts per thousand (8.1 %).
pub const VAT_PER_MILLE: u64 = 81;
/// `pub(super)`: visible to the parent module `billing`, and nowhere else.
pub(super) fn vat_cents(net_cents: u64) -> u64 {
net_cents * VAT_PER_MILLE / 1_000
}
  • mod tax; is private, so only billing can use billing::tax.
  • pub use tax::VAT_PER_MILLE; is a re-export: the constant is defined in tax, but callers see it as billing::VAT_PER_MILLE. Libraries use pub use to give a short public API while the files stay organized. Java has no equivalent.
  • pub(super) makes vat_cents visible to the parent module only.
pub mod shop {
pub mod catalog {
pub fn price_cents(item: &str) -> Option<u64> {
match item {
"book" => Some(2_000),
"pen" => Some(150),
_ => None,
}
}
}
pub mod cart {
use super::catalog; // `super`: the parent module, `shop`
pub fn total_cents(items: &[&str]) -> u64 {
items.iter().filter_map(|i| catalog::price_cents(i)).sum()
}
pub fn total_text(items: &[&str]) -> String {
self::format(total_cents(items)) // `self`: this module, `cart`
}
fn format(cents: u64) -> String {
crate::modules_crates_and_cargo::money::format_cents(cents) // `crate`: the crate root
}
}
}
pub mod money {
pub fn format_cents(cents: u64) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}
}
Path start Means Java
crate:: the root of this crate the fully qualified name in your project
super:: the parent module (none)
self:: this module (none)
serde:: an external crate, from Cargo.toml an import from a dependency
std:: the standard library java.*

use imports a name, like Java’s import. use super::catalog; then catalog::price_cents(...) is the common style for functions: import the module, and call through it. For types, import the type itself: use std::collections::HashMap;.

An edition is a version of the language that a crate chooses in Cargo.toml: edition = "2024". The editions are 2015, 2018, 2021, and 2024. An edition changes syntax and some defaults inside one crate. Crates of different editions work together in one program. Some examples:

  • 2018: extern crate is no longer needed, and paths start with crate::.
  • 2021: closures capture single fields instead of whole variables. IntoIterator for arrays gives values.
  • 2024: gen is a reserved keyword, unsafe extern blocks are required, and impl Trait in return types captures all lifetimes in scope by default.

When an agent writes edition = "2018" or no edition at all (which means 2015) in a new crate, ask for 2024. See S27: Outdated idioms (try!, extern crate, lazy_static, old edition).

A file build.rs next to Cargo.toml is a build script: Rust code that Cargo compiles and runs before it builds the crate. It generates code, compiles C libraries, or reads the environment. It is like a Maven plugin that runs in the generate-sources phase. In review, read it as carefully as the crate itself: it runs with your permissions on every build.

  • Few dependencies, each with a reason. Common, well-maintained crates for common tasks (see the library map in the reference section).
  • Version requirements without *, and a committed Cargo.lock for applications.
  • Only the features that the code uses.
  • Small public API: private modules, and pub use for the names that callers need.
  • The current edition (2024) in new crates.
  • A workspace when a project has several packages, like a multi-module Maven build.

The task: organize two money helpers and an invoice function.

  1. WorseThree levels of pub mod (utils::helpers::money), everything public, and a glob import that hides where names come from.

    pub mod utils {
    pub mod helpers {
    pub mod money {
    pub fn format_cents(cents: u64) -> String {
    format!("{}.{:02}", cents / 100, cents % 100)
    }
    pub fn round_to_francs(cents: u64) -> u64 {
    cents.div_ceil(100) * 100
    }
    }
    }
    }
    pub mod invoice {
    use super::utils::helpers::money::*; // which names does this import?
    pub fn total_text(cents: u64) -> String {
    format_cents(round_to_francs(cents))
    }
    }
    Run in Playground (opens the Rust Playground in a new tab)
  2. BetterA private money module, named imports, and one pub use for the function that callers need.

    mod money {
    pub fn format_cents(cents: u64) -> String {
    format!("{}.{:02}", cents / 100, cents % 100)
    }
    pub fn round_to_francs(cents: u64) -> u64 {
    cents.div_ceil(100) * 100
    }
    }
    pub mod invoice {
    use super::money::{format_cents, round_to_francs};
    pub fn total_text(cents: u64) -> String {
    format_cents(round_to_francs(cents))
    }
    }
    pub use invoice::total_text; // the one function that callers need, at a short path
    Run in Playground (opens the Rust Playground in a new tab)

The “worse” layout is a Java habit: a util package with sub-packages, and every class public. In Rust, every pub item in a library is part of its API. Here, callers can use worse::utils::helpers::money::round_to_francs, so it cannot be changed without breaking them. The glob import use ...::money::* makes the reader look up each name.

The “better” version keeps money private, imports names one by one, and re-exports total_text at a short path. Callers write better::total_text.

  1. A manifest asks for regex version 1.13. Which versions does Cargo accept?

    Show the answer

    Any version from 1.13.0 up to, but not including, 2.0.0. Cargo uses the newest one that fits, and records it in Cargo.lock.

  2. Where is the code of mod billing; declared in src/main.rs?

    Show the answer

    In src/billing.rs, or in the older layout src/billing/mod.rs.

  3. What does pub use tax::VAT_PER_MILLE; in billing.rs do?

    Show the answer

    It re-exports the constant: callers can write billing::VAT_PER_MILLE, even though it is defined in the private module billing::tax.

  4. An agent added tokio with version * and the full feature to a command-line tool with no async code. What do you ask?

    Show the answer

    Why tokio is needed at all. If it is needed, ask for a real version requirement instead of *, and only the features that the code uses.