Modules, crates and project structure

mod, pub and use, library versus binary targets, file and folder layout, workspaces, feature flags, dependency semver, and generated docs.

Modules, visibility and paths

// src/lib.rs — the crate root of a library
pub mod store;               // loads src/store.rs or src/store/mod.rs
pub mod config;

mod internal {               // inline module
    pub(crate) fn helper() {}      // visible anywhere in this crate
    fn secret() {}                 // visible only inside internal
}

pub use crate::store::Store;       // re-export: the public path becomes crate::Store
pub use internal::helper as helper_fn;

pub fn run() {
    let s = Store::open(":memory:");
    internal::helper();
    s.close();
}

// use brings names into scope; paths are absolute from crate:: or the crate root
use std::collections::HashMap;
use std::fmt::{self, Display};
  • Everything is private by default, and the default is the correct one. Add pub when a caller outside needs it, and pub(crate) when only your own code does.
  • A private item is visible to its own module and to descendants, which is why a private helper inside internal is reachable from nested modules below it.
  • pub use flattens a deep internal structure into a small public surface. Users import crate::Store, and you stay free to move the file that defines it.
  • self:: and super:: are the relative paths: super is the parent module. Prefer the absolute crate:: path — it survives moving the file.
  • In edition 2018 and later, use some_crate::Thing refers to a dependency without :: prefixes. There is no need for extern crate.

Layout: lib, bin and folders

notes/
  Cargo.toml
  src/
    lib.rs          library crate root: the reusable logic
    main.rs         binary crate root: a thin main that calls the library
    store.rs        module at src/store.rs
    config/
      mod.rs        module in a folder: submodules declared here
      parse.rs
      env.rs
    bin/
      migrate.rs    an extra binary, built as notes-migrate
  tests/
    integration.rs  integration tests: only the public API is visible
  benches/
    encode.rs
  examples/
    quickstart.rs
  doc/
PathWhat cargo does with it
src/lib.rsBuilds a library target named after the package; other crates can depend on it
src/main.rsBuilds a binary named after the package
src/bin/*.rsOne extra binary per file, named after the file
tests/*.rsIntegration tests, each compiled as its own crate against your public API
examples/*.rsCompiled on demand with cargo run --example name; also checked by cargo test
benches/*.rsBenchmarks, run with cargo bench
// src/main.rs — the binary is a thin shell around the library
use notes::{config, Store};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = config::load("notes.toml")?;
    let mut store = Store::open(&cfg.path);
    store.run()?;
    Ok(())
}

// a module can be declared in a folder with mod.rs, or as store.rs + store/ for
// submodules. Pick one convention per project: mixed styles are the usual cause of
// "file not found for module" errors.

// feature flags let one crate serve several profiles
#[cfg(feature = "sqlite")]
pub mod sqlite;

#[cfg(not(feature = "sqlite"))]
pub mod memory;
[features]
default = ["memory"]
memory = []
sqlite = ["dep:rusqlite"]
full = ["sqlite", "metrics"]

[dependencies]
rusqlite = { version = "0.32", optional = true }

Dependencies, semver and docs

  • Features must be additive. Enabling a feature may add code but must never remove or change existing behaviour, because Cargo unifies features across all dependents of a crate.
  • Never make a feature that silently switches an implementation for everyone. If two behaviours must be chosen, make them two crates or two types.
  • dep:rusqlite in a feature list hides the optional dependency from the feature namespace; the older implicit feature of the same name is the reason this syntax exists.
  • Semver in Cargo: 0.x versions are incompatible with each other for x > 0, so 0.9 to 0.10 is a breaking change even though the major digit is still zero. Pin the minor for pre-1.0 dependencies.
  • cargo tree -d lists crates present in more than one version. Duplicates are legal and often unavoidable, but they cost compile time and binary size.
  • cargo doc --no-deps --open builds the API documentation, and cargo doc --document-private-items includes internals while you design.
/// Opens a store at the given path.
///
/// # Errors
///
/// Returns an error if the path is not writable.
///
/// # Examples
///
/// ```
/// let store = notes::Store::open(":memory:");
/// assert!(store.is_open());
/// ```
pub fn open(path: &str) -> Store { /* ... */ }

// #![warn(missing_docs)] at the top of lib.rs turns missing docs into a warning
💡
Write the library first and the binary second. Keeping logic in src/lib.rs means integration tests, examples and future binaries all reach it through the public API, and the compiler checks that API from the outside. A program with everything in main.rs can only be tested from the command line.

FAQ

mod.rs or the newer file style?
Both work. store.rs plus a store/ folder is the modern style and avoids a screen full of mod.rs tabs. The only rule is to be consistent, because mixing the two in one crate produces confusing module-resolution errors.
Why can my integration test not see an item?
Tests under tests/ are separate crates and see only what is pub. Either export the item, or move the test into a #[cfg(test)] module inside the source file, where private items are reachable.

Setting up Rust: rustup, Cargo and edition 2024 Testing, documentation and CI

Last refreshed 2026-09-18.