Skip to content

Rust Crates, Modules, Paths and Visibility

Rust modules organise code inside a crate; a Cargo package can contain both a reusable library crate and an executable binary crate. This lesson separates the fixture parser from terminal output and exposes one public entry point without publishing its internal module layout.

Prerequisites and outcome

Complete Option, Result and error handling. You will distinguish packages from crates and modules, follow paths from the correct crate root, and test a public API from outside the library. The code uses fixture text and no external dependencies.

Package, crate and module are not synonyms

Concept Role in this project
Cargo package pi_modules, described by Cargo.toml
Library crate src/lib.rs is its root; reusable parsing API
Binary crate src/main.rs is its root; terminal reporting
Module readings, declared in the library and implemented in src/readings.rs

The binary and library are separate crates even when stored in one package. A module is not separately installed or linked merely because it lives in another file. Cargo conventions locate crate roots; mod declarations locate modules. See packages and crates.

Build the complete multi-file project

1
2
3
cargo new pi_modules
cd pi_modules
mkdir -p tests

Create or replace the files below in this project. Keep the package name as shown because the binary and external test import it by name. Cargo.lock and target/ are generated by Cargo, not additional source files to copy.

Cargo.toml

1
2
3
4
5
6
[package]
name = "pi_modules"
version = "0.1.0"
edition = "2024"

[dependencies]

src/lib.rs — expose the public entry point

mod readings;

// Export the function without exposing the readings module itself.
pub use readings::parse_reading;

#[cfg(test)]
mod tests {
    use super::parse_reading;

    #[test]
    fn public_entry_preserves_missing_input() {
        assert_eq!(parse_reading(" ").expect("valid fixture"), None);
    }

    #[test]
    fn public_entry_preserves_real_zero() {
        assert_eq!(parse_reading("0").expect("valid fixture"), Some(0));
    }
}

src/readings.rs — keep implementation details local

use std::num::ParseIntError;

fn normalise(input: &str) -> &str {
    input.trim()
}

pub fn parse_reading(input: &str) -> Result<Option<i32>, ParseIntError> {
    let text = normalise(input);
    if text.is_empty() {
        return Ok(None);
    }
    Ok(Some(text.parse::<i32>()?))
}

#[cfg(test)]
mod tests {
    use super::{normalise, parse_reading};

    #[test]
    fn private_helper_trims_without_changing_inner_text() {
        assert_eq!(normalise(" 46 700 "), "46 700");
    }

    #[test]
    fn parser_rejects_invalid_or_overflowing_numbers() {
        assert!(parse_reading("bad").is_err());
        assert!(parse_reading("2147483648").is_err());
    }
}

src/main.rs — use the library, not its internal module

use pi_modules::parse_reading;

fn main() {
    for input in ["46700", "", "bad"] {
        match parse_reading(input) {
            Ok(Some(value)) => println!("measured={value}"),
            Ok(None) => println!("missing"),
            Err(_error) => println!("invalid"),
        }
    }
}

tests/public_api.rs — verify the outside-facing boundary

1
2
3
4
5
6
7
use pi_modules::parse_reading;

#[test]
fn external_caller_uses_the_reexport() {
    assert_eq!(parse_reading(" -500 ").expect("valid fixture"), Some(-500));
    assert!(parse_reading("invalid").is_err());
}

Run from the package directory:

1
2
3
4
5
cargo check
cargo run --quiet
cargo test
cargo fmt --check
cargo doc --no-deps

Expected program output:

1
2
3
measured=46700
missing
invalid

The project contains four library unit tests and one integration test. The integration test is a separate crate that uses the library as an external caller; it cannot directly reach private helpers. The later testing lesson explains the different test categories in depth.

Declarations and imports do different work

mod readings; declares the library's child module and loads its code from src/readings.rs. Merely creating that file does not add it to the module tree. use brings an accessible path into scope; it neither creates a module nor bypasses privacy.

Do not also put mod readings; into main.rs just to call the parser. That would declare another module in the binary crate rather than reuse the library's public entry point. Our binary imports pi_modules::parse_reading and Cargo connects it to the library in the same package.

An inline declaration, mod readings { ... }, puts the module body in the declaring file. For a child declared from src/readings.rs, a further external module would normally live under src/readings/. File organisation supports the module tree, but is not a substitute for the declarations. See module organisation.

Paths start from a specific context

  • crate:: starts at the current crate's root. In main.rs it refers to the binary, not the library just because both share a package.
  • self:: starts from the current module; it is unrelated to a method receiver called self.
  • super:: starts from the parent module. Our child test modules use it to access their parent items.
  • pi_modules:: names the library from a consuming crate, including the binary and integration test here.

The pub use in lib.rs can equivalently use crate::readings::parse_reading. An alias such as use pi_modules::parse_reading as parse; introduces a local name without renaming the public API. Grouped imports reduce repetition; glob imports such as use module::* should not obscure which API a beginner's example relies on. See paths and use declarations.

Rust visibility defines the API boundary

The module is private, but its function is public and re-exported by the library root. Outside callers depend on the root function path rather than our chosen implementation directory. They cannot call normalise, which is private to readings and its descendants.

Private items are accessible in their defining module and its descendants, subject to the path's visibility; a parent cannot freely call a child's private helper. pub(crate) restricts an item to the current crate, while pub(super) restricts it to its parent scope and descendants. pub(in crate::some_module) expresses an ancestor-module scope where applicable. These are compile-time API rules, not a security sandbox against arbitrary code execution.

A public struct does not automatically make its fields public. Private fields can force outside construction through public associated functions. Public enum variants, in contrast, follow the enum's visibility. We will use those differences when defining reusable interfaces. Consult the visibility reference for exact rules.

Deliberately failing access through a private module

Keep the library files above unchanged. In a separate copy of the project, replace only src/main.rs with:

1
2
3
4
5
use pi_modules::readings::parse_reading;

fn main() {
    println!("{:?}", parse_reading("46700"));
}

cargo check should report E0603 because readings is private. The root re-export is the supported path. Making every module public solely to remove an error broadens the API unnecessarily.

Exercises and troubleshooting

  1. Change lib.rs to pub use crate::readings::parse_reading: tests and output should remain unchanged.
  2. Remove pub from parse_reading in readings.rs: the public re-export should fail (E0603). Restore it rather than claiming a private function is publicly exposed.
  3. Change parse_reading to pub(crate): outside-library use or re-export should fail (E0364 for this root re-export). The binary is a different crate, so “same package” does not grant access.
  4. Alias the binary's import to parse and update its call: expect unchanged output. The integration test still uses the public function's original name.
  5. Try crate::parse_reading in the binary without importing a root name: expect a missing-item error. The library and binary have distinct roots.

If Cargo cannot find a module file, compare the mod declaration and filename. If a path is private, inspect each segment and the intended API, not just the final function. If the crate name is unresolved, check Cargo.toml and remember that package names containing hyphens normally become underscore-separated crate identifiers.

Verification and next step

On October 10, 2026, all five project files were verified together on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, four library tests, one integration test, formatting, documentation generation and debug/release output comparisons passed. The absolute re-export and aliased-import exercises also passed. Private module/function access produced E0603, crate-restricted re-export produced E0364, and the wrong binary-root path produced E0425. The repeatable checker extracts the marked files from this article. No hardware access or performance result is involved.

Next: generic types, bounds and const generics, reusing code while making its requirements explicit.

Previous: error handling · Course overview

Donate