Organizing code & Project structure

Now that the samples are getting larger, it's a good moment to discuss how Rust code can be organized. There are different ways a Rust project can be structured. We'll look at some best practices and common patterns.

Rust code can be split into multiple .rs files. Each Rust file is called a module. These modules must be kept together by a 'parent' Rust file. This parent can be one of the following:

  • main.rs for applications
  • lib.rs for a library
  • mod.rs for sub-modules

An application with a main.rs and a single module person.rs is structured like this:

|- Cargo.toml
|- src
    |- main.rs
    |- person.rs

The main.rs must 'stitch' the modules together using a mod statement. In our case: mod person.

Filename: src/person.rs

#[derive(Debug)]
pub struct Person {
    pub name: String,
}

Filename: src/main.rs

use crate::person::Person;

mod person;

fn main() {
    let me = Person {
        name: "Marcel".to_string(),
    };
    println!("{:?}", me);
}

Output

Person { name: "Marcel" }

The pub keyword in front of struct and name means that we expose these items for use outside of the module, i.e. make them public.

The use statement imports the type(s) from the module, whereby sub-modules are separated by ::

crate refers to the current project.

Ferris organizing

Splitting files into submodules

A single Rust file can hold multiple modules. Each module is identified and scoped with a mod {...} statement for private modules, or pub mod {...} for public modules.

|- Cargo.toml
|- src
    |- main.rs
    |- database.rs

Filename: src/database.rs

pub mod project {
    #[derive(Debug)]
    pub struct Project {
        pub name: String,
    }
}

pub mod person {
    use crate::database::project::Project;

    #[derive(Debug)]
    pub struct Person {
        pub name: String,
        pub project: Option<Project>,
    }
}

Filename: src/main.rs

use crate::database::person::Person;
use crate::database::project::Project;

mod database;

fn main() {
    let project = Project {
        name: "Rust book".to_string(),
    };
    let person = Person {
        name: "Marcel".to_string(),
        project: Some(project),
    };

    println!("{:?}", person);
}

Output

Person { name: "Marcel", project: Some(Project { name: "Rust book" }) }

Typically, each module is kept in its own file, and the mod statement is used to 'stitch' the modules together. The exception is tests, which are often kept in the same file as the module they are testing.

Here's an example of a test in the same file as the module:

fn main() {
    let my_greeting = reverse_string("Hello World");
    println!("{my_greeting}");
}

fn reverse_string(input: &str) -> String {
    input.chars().rev().collect()
}

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

    #[test]
    fn test_reverse_string() {
        assert_eq!(reverse_string("Hello World"), "dlroW olleH");
    }
}

By the way, you can run the tests with cargo test from the command line. This will run all tests in the project. We'll cover testing in more detail in a later chapter.

Larger modules

As your project grows, you may want to separate the modules into different files and group these into a submodule. To do this, you create a new directory under src with the name of the module and include an mod.rs file that typically only contains the mod statements to 'stitch' the Rust files of the module together.

|- Cargo.toml
|- src
    |- main.rs
    |- database
          |- mod.rs
          |- person.rs
          |- project.rs

Filename: src/database/mod.rs

pub mod person;
pub mod project;

Filename: src/database/project.rs

#[derive(Debug)]
pub struct Project {
    pub name: String,
}

Filename: src/database/person.rs

use crate::database::project::Project;

#[derive(Debug)]
pub struct Person {
    pub name: String,
    pub project: Option<Project>,
}

Filename: src/main.rs

use crate::database::person::Person;
use crate::database::project::Project;

mod database;

fn main() {
    let project = Project {
        name: "Rust book".to_string(),
    };
    let person = Person {
        name: "Marcel".to_string(),
        project: Some(project),
    };

    println!("{:?}", person);
}

Output

Person { name: "Marcel", project: Some(Project { name: "Rust book" }) }

The layout you choose depends a lot on the type of project you're working on. If you're working with a larger team on the same codebase, it may be easier to split modules into separate files.

Workspaces

Even larger projects can be split into separate binaries and libraries, that are kept together in a workspace. The Cargo Workspaces chapter in the Rust book explains this concept in detail.

Let's say we want to split the database from our binary into a reusable library. We can do this by creating the following workspace structure:

|- Cargo.toml
|- database_lib
|   |- Cargo.toml
|   |- src
|       |- lib.rs
|       |- person.rs
|       |- project.rs
|- my_project
    |- Cargo.toml
    |- src
        |- main.rs

The top-level Cargo.toml would look like this:

[workspace]
members = [
    "database_lib",
    "my_project",
]
resolver = "2"

The Cargo.toml in the database_lib would look like this:

[package]
name = "database_lib"
version = "0.1.0"
edition = "2021"

[dependencies]

The Cargo.toml in the my_project would look like this:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[dependencies]
database_lib = { path = "../database_lib" }

The lib.rs would not include a main function, but instead a pub mod statement for each module:

pub mod person;
pub mod project;

If you push this project to GitHub (or any other Git repository) , you can use the dependencies section in the Cargo.toml to include the database_lib in another project. See the next chapter for more details.

Make sure to make all the types you want to use in the my_project public by adding pub in front of the type.

Using types from external modules

In the above example we are importing the Project-type through the use crate::database::project::Project statement. By doing so, we can use Project in the rest of the code, without the need for typing out the full path all the time.

Alternatively, we could have written:

mod database;

fn main() {
    let project = crate::database::project::Project {
        name: "Rust book".to_string(),
    };
    let person = crate::database::person::Person {
        name: "Marcel".to_string(),
        project: Some(project),
    };

    println!("{person:?}");
}

You can see how such code can become very verbose, very quickly.

In the case of a naming conflict - two types from different crates share the same name - you can import the type's path, and associate an alias. Like so:

use crate::database::person as prs;
use crate::database::project as prj;

mod database;

fn main() {
    let project = prj::Project {
        name: "Rust book".to_string(),
    };
    let person = prs::Person {
        name: "Marcel".to_string(),
        project: Some(project),
    };

    println!("{person:?}");
}

You can also create an alias for a specific type:

use crate::database::person::Person as Someone;
use crate::database::project::Project;

mod database;

fn main() {
    let project = Project {
        name: "Rust book".to_string(),
    };
    let person = Someone {
        name: "Marcel".to_string(),
        project: Some(project),
    };

    println!("{person:?}");
}

Reference material