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 applicationslib.rs
for a librarymod.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 ofstruct
andname
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.
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 theCargo.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 addingpub
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:?}");
}