Intermediate Topics: Modules, Crates, and the Cargo Ecosystem

Intermediate Topics: Modules, Crates, and the Cargo Ecosystem

As your Rust projects grow in complexity, organizing your code becomes paramount for maintainability, reusability, and collaboration. Rust provides a robust module system, managed by its powerful build tool and package manager, Cargo. This chapter will guide you through understanding Rust’s project hierarchy, controlling visibility, and leveraging the rich Cargo ecosystem.

Understanding the Hierarchy: Packages, Crates, and Modules

Rust’s code organization follows a clear hierarchy:

  1. Packages: The highest level of organization. A package is what Cargo builds. It contains:

    • A Cargo.toml file that describes the package (metadata, dependencies, etc.).
    • One or more crates.
    • Can contain a binary crate (executable, default src/main.rs), a library crate (default src/lib.rs), or both.
  2. Crates: The fundamental compilation unit in Rust.

    • A library crate produces a library (e.g., .rlib file).
    • A binary crate produces an executable.
    • Every crate has an implicit crate root module, which is src/main.rs for a binary crate or src/lib.rs for a library crate.
  3. Modules: Organize code within a crate. They allow you to group related functions, structs, enums, etc., and control their visibility (whether they are public or private).

Think of a package as a book, a crate as a chapter within that book, and modules as sections within that chapter.

Modules and mod Keyword

You define a module using the mod keyword.

Declaring Modules

Modules can be declared inline within a file or in separate files/directories.

Inline Modules

// src/main.rs or src/lib.rs

mod front_of_house {
    fn serve_food() {
        println!("Serving delicious food!");
    }

    mod hosting { // Nested module
        fn add_to_waitlist() {
            println!("Added to waitlist.");
        }
    }
}

fn main() {
    // You cannot directly access `serve_food()` here because it's private to `front_of_house`.
    // front_of_house::serve_food(); // ERROR!
    // front_of_house::hosting::add_to_waitlist(); // ERROR!
}

By default, items within a module are private to that module. To make them accessible from parent modules or other modules, you use the pub keyword.

Separating Modules into Files

For larger projects, it’s common practice to put modules in their own files.

If you declare mod front_of_house; in src/lib.rs or src/main.rs, Rust will look for the code for that module in one of two places:

  1. src/front_of_house.rs
  2. src/front_of_house/mod.rs (the traditional way, now less common in new projects for top-level modules, but still valid for nested modules in subdirectories).

Example: src/lib.rs

// src/lib.rs
pub mod front_of_house; // Declares the module and tells Rust to look for its code

Example: src/front_of_house.rs

// src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {
        println!("Adding to waitlist in front of house module!");
    }

    pub fn seat_at_table() {
        println!("Seating at table in front of house module!");
    }
}

pub mod serving {
    pub fn take_order() {
        println!("Taking order in front of house module!");
    }
}

Now, if you have src/main.rs that uses this library:

// src/main.rs
use my_restaurant_lib::front_of_house::hosting; // Use the path to the module

fn main() {
    hosting::add_to_waitlist(); // Now accessible
    // hosting::seat_at_table();
    // my_restaurant_lib::front_of_house::serving::take_order();
}

Here, my_restaurant_lib would be the name of your library crate, as defined in Cargo.toml.

Paths for Referring to Items in the Module Tree

To refer to an item within a module, you use its path. Paths can be:

  • Absolute Paths: Start from the crate root (crate:: for the current crate, or the crate name for an external crate).
  • Relative Paths: Start from the current module (self:: for the current module, or super:: for the parent module).
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {
            println!("Patron added to waitlist.");
        }
    }
}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::front_of_house::hosting::add_to_waitlist(); // Relative path to parent's child
    }

    fn cook_order() {
        println!("Cooking order.");
    }
}

fn eat_at_restaurant() {
    // Absolute path to a function
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path to a function
    front_of_house::hosting::add_to_waitlist();
}

fn main() {
    eat_at_restaurant();
}

pub and Privacy Rules

Rust’s privacy model is explicit and designed to protect implementation details.

  • By default, all items (functions, methods, structs, enums, modules, constants) are private.
  • To make an item public, you must explicitly use the pub keyword.

pub on Structs and Enums

When you make a struct public, its fields are still private by default. You need to mark individual fields as pub if you want external code to access them directly.

mod back_of_house {
    pub struct Breakfast {
        pub toast: String, // Public field
        seasonal_fruit: String, // Private field
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

fn main() {
    let mut meal = back_of_house::Breakfast::summer("Rye");
    meal.toast = String::from("Wheat"); // OK: toast is public
    // meal.seasonal_fruit = String::from("blueberries"); // ERROR: seasonal_fruit is private
    println!("I'd like {} toast please", meal.toast);
}

When you make an enum public, all its variants are automatically public. This makes sense, as an enum with private variants would not be very useful.

mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

fn main() {
    let order1 = back_of_house::Appetizer::Soup; // OK: Soup variant is public
    let order2 = back_of_house::Appetizer::Salad; // OK: Salad variant is public
}

The use Keyword

The use keyword brings a path into scope, allowing you to refer to items by shorter names.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// Bring the `hosting` module into scope
use crate::front_of_house::hosting;

fn main() {
    hosting::add_to_waitlist(); // Now we can call it directly
}

Idiomatic use Paths

  • Functions: It’s idiomatic to bring the function’s parent module into scope, rather than the function itself. This helps to avoid name collisions and makes it clear that the function is from a module.
    use crate::front_of_house::hosting; // Idiomatic
    // use crate::front_of_house::hosting::add_to_waitlist; // Less idiomatic for functions
    
  • Structs, Enums, Traits: It’s idiomatic to bring the full path, including the item itself, into scope.
    use std::collections::HashMap;
    let mut map = HashMap::new();
    

Nested Paths for use

You can use nested paths to bring multiple items from the same module into scope with one use statement.

use std::{cmp::Ordering, io}; // Brings Ordering from std::cmp and io from std
use std::collections::{self, HashMap, HashSet}; // Brings collections module itself, HashMap, and HashSet

The as Keyword for Aliases

Use as to rename an item you’re bringing into scope, especially to resolve name conflicts.

use std::fmt::Result;
use std::io::Result as IoResult; // Renames std::io::Result to IoResult

fn function1() -> Result { /* ... */ Ok(()) } // Refers to std::fmt::Result
fn function2() -> IoResult<()> { /* ... */ Ok(()) } // Refers to std::io::Result

pub use for Re-exporting

pub use allows you to make an item available to external code at a different, often more convenient, path. This is key for designing user-friendly public APIs for your libraries.

// src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting; // Re-export `hosting`

fn main() {
    hosting::add_to_waitlist(); // This still works internally

    // External users of this library can now call:
    // my_crate::hosting::add_to_waitlist();
    // instead of my_crate::front_of_house::hosting::add_to_waitlist();
}

The Cargo Ecosystem

Cargo is much more than just a build tool; it’s the heart of the Rust ecosystem.

Cargo.toml: The Manifest File

Every Rust package has a Cargo.toml file at its root. This is a TOML (Tom’s Obvious, Minimal Language) file that contains metadata about your project.

[package]
name = "my_app"
version = "0.1.0"
edition = "2024" # The current default edition for new projects

[dependencies]
# External dependencies (crates from crates.io)
rand = "0.8"       # For random number generation
serde = { version = "1.0", features = ["derive"] } # For serialization/deserialization
  • [package]: Contains information about your project.
  • [dependencies]: Lists external crates your project needs. Cargo automatically downloads, compiles, and links these.
  • edition = "2024": Specifies the Rust edition to use for your crate. The Rust 2024 Edition (stabilized with Rust 1.85.0) brings several quality-of-life improvements and language changes, while maintaining backward compatibility with older editions. It introduces features like improved impl Trait lifetime capture rules, adjusted temporary scopes for if let and tail expressions, and stricter unsafe usage requirements.

Standard Cargo Commands

  • cargo new <project_name>: Creates a new Rust project (package) with a src/main.rs (binary crate) and Cargo.toml. Add --lib to create a library crate instead.
  • cargo build: Compiles your project. Creates a target/debug directory.
  • cargo run: Compiles and runs your project.
  • cargo check: Compiles your project without producing an executable, useful for quickly checking for errors.
  • cargo test: Runs all tests in your project.
  • cargo add <crate_name>: (Since Rust 1.62.0) A convenient way to add dependencies to Cargo.toml.
    • Example: cargo add rand
    • Example: cargo add serde --features serde_derive (for old serde_derive feature, now usually just features = ["derive"])
  • cargo update: Updates dependencies to their latest compatible versions.
  • cargo clean: Removes the target directory.
  • cargo fmt: Formats your code according to Rust style guidelines (rustfmt tool).
  • cargo clippy: Runs a linter to catch common mistakes and idiomatic errors (Clippy tool).

Workspaces (for multi-crate projects)

For projects with multiple related crates, a Cargo workspace allows them to share a common Cargo.lock file and target directory, making builds faster and dependency management easier.

my-workspace/
├── Cargo.toml          # Workspace manifest
├── core-library/       # A library crate
│   └── Cargo.toml
│   └── src/lib.rs
├── cli-app/            # A binary crate using core-library
│   └── Cargo.toml
│   └── src/main.rs
└── another-tool/       # Another binary crate
    └── Cargo.toml
    └── src/main.rs

The workspace Cargo.toml looks like:

[workspace]
members = [
    "core-library",
    "cli-app",
    "another-tool",
]

Each member crate will then refer to other local crates in its Cargo.toml:

# cli-app/Cargo.toml
[dependencies]
core-library = { path = "../core-library" } # Local path dependency

Exercises / Mini-Challenges

Exercise 6.1: Restaurant Module Refactor

Take the front_of_house and back_of_house module structure we discussed and organize it into a small library crate named restaurant_menu. Create a separate binary crate that uses this library.

Instructions:

  1. Create a new package: cargo new restaurant_project.
  2. Inside restaurant_project, create a new library crate: cargo new --lib restaurant_menu. This will create restaurant_project/restaurant_menu/src/lib.rs.
  3. Move the front_of_house and back_of_house modules (and their contents, with pub where necessary) into restaurant_menu/src/lib.rs.
  4. In restaurant_project/src/main.rs (the binary crate), add restaurant_menu as a dependency in its Cargo.toml.
  5. In restaurant_project/src/main.rs, use items from restaurant_menu (e.g., call add_to_waitlist).
  6. Ensure all necessary pub keywords are in place for the items you want to access from the binary crate.
  7. Run cargo run from the restaurant_project root.

Example restaurant_project/restaurant_menu/src/lib.rs:

// In restaurant_menu/src/lib.rs
pub mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {
            println!("Waitlist: New guest added.");
        }
    }
    // Add other front_of_house items here
}

pub mod back_of_house {
    pub struct Chef {
        name: String,
    }

    impl Chef {
        pub fn new(name: String) -> Chef {
            Chef { name }
        }

        pub fn cook_dish(&self, dish: &str) {
            println!("Chef {} is cooking {}.", self.name, dish);
        }
    }
}

Example restaurant_project/src/main.rs:

// In restaurant_project/src/main.rs
use restaurant_menu::front_of_house::hosting;
use restaurant_menu::back_of_house::Chef;

fn main() {
    println!("Welcome to our Rust restaurant!");
    hosting::add_to_waitlist();
    
    let head_chef = Chef::new(String::from("Gordon"));
    head_chef.cook_dish("Pasta Carbonara");
}

Example restaurant_project/Cargo.toml (for the binary crate):

# In restaurant_project/Cargo.toml
[package]
name = "restaurant_project"
version = "0.1.0"
edition = "2024"

[dependencies]
restaurant_menu = { path = "restaurant_menu" }

Exercise 6.2: Using an External Crate (rand)

Modify your restaurant_project to use the rand crate to simulate a random event or decision.

Instructions:

  1. Add rand as a dependency to restaurant_project/Cargo.toml (the binary crate).
    • cargo add rand (run from restaurant_project directory).
  2. In restaurant_project/src/main.rs, use rand::thread_rng().gen_range(1..=10) to generate a random number between 1 and 10 (inclusive).
  3. Use an if or match statement based on this random number to:
    • If the number is 1, print “A new VIP guest arrived, seating immediately!”.
    • Otherwise, call hosting::add_to_waitlist() normally.
// Solution Hint:
/*
// In restaurant_project/src/main.rs
use rand::Rng; // Bring Rng trait into scope

fn main() {
    // ... previous code ...

    let mut rng = rand::thread_rng();
    let lucky_number = rng.gen_range(1..=10);

    if lucky_number == 1 {
        println!("A new VIP guest arrived, seating immediately!");
    } else {
        hosting::add_to_waitlist();
    }
}
*/

Exercise 6.3: Create a Small Workspace

Convert your restaurant_project into a Cargo workspace containing both the restaurant_menu library and the restaurant_project binary.

Instructions:

  1. Rename restaurant_project directory to restaurant-workspace.
  2. Move the contents of the original restaurant_project into a new subdirectory named app. So you’ll have:
    restaurant-workspace/
    ├── app/
    │   ├── Cargo.toml
    │   └── src/main.rs
    └── restaurant_menu/
        ├── Cargo.toml
        └── src/lib.rs
    
  3. Create a Cargo.toml file at the root of restaurant-workspace. This will be your workspace manifest.
    # In restaurant-workspace/Cargo.toml
    [workspace]
    members = [
        "app",
        "restaurant_menu",
    ]
    
  4. Update app/Cargo.toml to correctly reference restaurant_menu as a local path dependency within the workspace.
    # In restaurant-workspace/app/Cargo.toml
    [package]
    name = "app" # or whatever you named it
    version = "0.1.0"
    edition = "2024"
    
    [dependencies]
    restaurant_menu = { path = "../restaurant_menu" }
    rand = "0.8" # If you added rand in previous exercise
    
  5. Run cargo check or cargo run from the restaurant-workspace root directory to verify everything compiles and runs.