Arranging a Rust Project

Article · 15:00

Cargo decides more about structure than you do, and its decisions are good ones. What is left to you is where the boundaries go, and a module system that is private by default makes those explicit - with the trait as the boundary, newtypes, and doc tests that run.

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; pub and pub(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 tests sees private items; tests/ sees only the public API.
  • cargo fmt, cargo clippy -D warnings and doc tests in CI, and spend review on design.