Arranging a Rust Project
Cargo decides more about structure than you do, and the decisions it makes are good ones. What is left to you is where the boundaries go, and Rust's module system - private by default, public only where you say - makes those boundaries real rather than advisory.
fn main() {
println!("A typical binary-plus-library project:
Cargo.toml
src/
main.rs the binary: argument parsing, wiring, and nothing else
lib.rs the crate root: `pub mod` for what the world may see
domain/
mod.rs `pub` only what callers need
course.rs
money.rs
storage/
mod.rs
mysql.rs not pub: an implementation detail of this crate
tests/
integration.rs compiled as a separate crate, so it sees only the public API
benches/
throughput.rs
`cargo new --lib` then `cargo new` for the binary, or one package with both -
Cargo finds src/main.rs and src/lib.rs without being told.");
}
Modules: private by default
mod catalogue {
// Private to this module unless marked pub.
#[derive(Debug, Clone)]
pub struct Course {
pub slug: String,
pub title: String,
internal_id: u32, // no pub: invisible outside this module
}
impl Course {
pub fn new(slug: &str, title: &str) -> Self {
Self {
slug: slug.to_string(),
title: title.to_string(),
internal_id: 0,
}
}
// pub(crate): visible in this crate, not to anyone who depends on it.
pub(crate) fn id(&self) -> u32 {
self.internal_id
}
}
pub mod search {
use super::Course; // super is the parent module
pub fn matching<'a>(courses: &'a [Course], term: &str) -> Vec<&'a Course> {
courses.iter().filter(|c| c.title.contains(term)).collect()
}
}
}
use catalogue::{search, Course};
fn main() {
let courses = vec![
Course::new("rust", "Rust: From Basics to Expert"),
Course::new("go", "Go: From Basics to Expert"),
];
for course in search::matching(&courses, "Rust") {
println!("/{} -> {} (internal id {})", course.slug, course.title, course.id());
}
// println!("{}", courses[0].internal_id); // private: will not compile
}
pub is the whole access control system: nothing is visible outside its module
unless you say so, and pub(crate) marks the things that are shared internally
but are not part of your public API.
The domain is plain Rust, and the trait is the boundary
use std::collections::HashMap;
// --- domain ---------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
pub struct Course {
pub slug: String,
pub title: String,
}
#[derive(Debug, PartialEq)]
pub enum Outcome {
Found(Course),
NotFound { slug: String },
}
// The port. Declared here, with the rules that need it.
pub trait CourseRepository {
fn find_by_slug(&self, slug: &str) -> Option<Course>;
}
pub struct ViewCourse<R: CourseRepository> {
courses: R,
}
impl<R: CourseRepository> ViewCourse<R> {
pub fn new(courses: R) -> Self {
Self { courses }
}
pub fn handle(&self, slug: &str) -> Outcome {
match self.courses.find_by_slug(slug) {
Some(course) => Outcome::Found(course),
None => Outcome::NotFound { slug: slug.to_string() },
}
}
}
// --- edge -----------------------------------------------------------------
pub struct InMemory {
rows: HashMap<String, Course>,
}
impl CourseRepository for InMemory {
fn find_by_slug(&self, slug: &str) -> Option<Course> {
self.rows.get(slug).cloned()
}
}
fn main() {
let rows = HashMap::from([(
"rust".to_string(),
Course { slug: "rust".into(), title: "Rust: From Basics to Expert".into() },
)]);
let use_case = ViewCourse::new(InMemory { rows });
for slug in ["rust", "cobol"] {
match use_case.handle(slug) {
Outcome::Found(course) => println!("found {} at /{}", course.title, course.slug),
Outcome::NotFound { slug } => println!("no course called {slug}"),
}
}
}
ViewCourse<R: CourseRepository> is static dispatch: no allocation, no vtable,
and the real repository and the test one are different types with no runtime
cost. Use Box<dyn CourseRepository> instead when you need to choose at runtime.
The newtype, which is free and prevents a whole class of bug
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Pence(i64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CourseId(u32);
impl Pence {
fn as_pounds(self) -> f64 {
self.0 as f64 / 100.0
}
}
fn charge(id: CourseId, amount: Pence) {
println!("charging {:.2} for course {}", amount.as_pounds(), id.0);
}
fn main() {
let id = CourseId(7);
let price = Pence(1999);
charge(id, price);
// charge(price, id); // will not compile: the types are not the same
// charge(CourseId(7), 1999) // nor will a bare integer
println!("{:?} {:?}", price, Pence(500).max(price));
println!("a newtype costs nothing at runtime and makes the mix-up impossible");
}
Tests live next to the code
pub fn categorise(n: i32) -> &'static str {
match n {
i32::MIN..=-1 => "negative",
0 => "zero",
1..=99 => "small",
_ => "large",
}
}
// A real project puts this at the bottom of the same file. It is compiled only
// under `cargo test`, so it costs nothing in the shipped binary - and because
// it is a child module it can see private items.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn categorises_every_range() {
for (input, want) in [(-1, "negative"), (0, "zero"), (5, "small"), (500, "large")] {
assert_eq!(categorise(input), want, "for input {input}");
}
}
}
fn main() {
// The same cases, run as a program so this lesson demonstrates the shape.
let cases = [(-1, "negative"), (0, "zero"), (5, "small"), (500, "large")];
let failures = cases.iter().filter(|(input, want)| categorise(*input) != *want).count();
println!("{} cases, {failures} failures", cases.len());
println!("in a real crate: cargo test, and `mod tests` sees private items");
}
tests/ at the top level is the other half: those files are separate crates, so
they see only your public API - which is the best way to find out whether it is
usable.
What the toolchain should enforce
fn main() {
println!("cargo fmt one formatting, applied - no style review
cargo clippy -- -D warnings
the lint suite; it teaches idiom, not just correctness
cargo test unit, integration and doc tests in one command
cargo doc --open documentation from /// comments, with the examples compiled
/// comments are compiled AND RUN by cargo test, so a documentation example
/// that goes stale fails the build. No other mainstream language does this,
/// and it is the reason Rust documentation tends to be correct.");
}
What to take away
- Modules are private by default;
pubandpub(crate)are the whole access system. - Declare the trait with the rules that need it, and implement it at the edge.
- Generic over a trait is static dispatch and free;
Box<dyn Trait>when runtime choice is needed. - A newtype (
struct Pence(i64)) costs nothing and makes an argument mix-up impossible. #[cfg(test)] mod testssees private items;tests/sees only the public API.cargo fmt,cargo clippy -D warningsand doc tests in CI, and spend review on design.