Home Rust Toolchains and Project Structures
Post
Cancel

Rust Toolchains and Project Structures

Key outcomes:

  • Choosing right configuration of Rust
  • Cargo introduction and project structure
  • Cargo build management
  • Cargo dependencies
  • Writing test scripts and doing automated unit and integration testing
  • Automating the generation of technical document

I. Choosing right configuration

A toolchain is a combination of a release channel and a host, and optionally also has an associated archive date.

1. Rust release channel

  • stable: for production use
  • beta: interim stage to verify that there isn’t any regression in Rust language releases before they are marked stable
  • nightly: for experimental features

To install nightly Rust, use command:

rustup toolchain install nightly

To active nightly Rust globally, use command:

rustup default nightly

To active nightly at a directory level, use command:

rustup override set nightly

To get the version of the compiler in nightly Rust, use command:

rustup run nightly rustc --version

To reset rustup to use the stable channel, use command:

rustup default stable

To show the installed toolchains and which is currently active, use command:

rustup show

To update the installed toolchains to the latest versions, use command:

rustup update


2. Rust project type

  • libraries:
    • library crate (or lib crate)
    • can be published to a public package repository (crates.io)
    • library crate begins in the src/lib.rs file
  • binaries
    • binary crate (or bin crate)
    • standalone executable: download or link other libraries into a single binary
    • binary crate starts in the main() function in src/main.rs

II. Project structure

  • Workspace: 1..n packages
  • Package: 0..1 library, 0..n binary, but minimum 1 crate
  • Crate: 0..n modules
  • Module: 1..n source files
  • Source file: 0..n functions

III. Cargo build management

1. Building basic binary crate

cargo new --bin bin-program && cd bin-program

  • --bin flag is to tell Cargo to generate a package that, when compiled, would produce a binary crate (executable)
  • bin-program is the name of the package given

Cargo.toml:

1
2
3
4
5
6
[package]
name = "bin-program"
version = "0.1.0"
edition = "2024"

[dependencies]

src/main.rs:

1
2
3
fn main() {
    println!("Hello, world!");
}

To generate a binary crate (or executable) from this package, run command:

cargo build

Execute the following from the command line:

cargo run

Result:

Hello, world!


By default, the name of the crate binary crate (executable) generated is the same as the name of the source package. If you wish to change the name of the binary, add the following lines to Cargo.toml:

1
2
3
[[bin]]
name = "bin-program"
path = "src/main.rs

Run the following in the command line:

cargo run --bin bin-program

Resutl:

Hello, world!

A cargo package can contain the source for multiple binaries. Add a new [[bin]] target below the first one:

1
2
3
4
5
6
7
[[bin]]
name = "bin-program"
path = "src/main.rs"

[[bin]]
name = "second-bin-program"
path = "src/second-main.rs"

Add to second-main.rs:

1
2
3
fn main(){
	println!("Hello, for the second time!")
}

Run following:

cargo run --bin second-bin-program

Result:

Hello, for the second time!


Configuring Cargo

A cargo package has an associated Cargo.toml file, which is also called the manifest.

  • Specifying output targets for the package: 5 types of targets:

    • [ [bin] ]: A binary target is an executable program that can be run after it is built.
    • [lib]: A library target produces a library that can be used by other libraries and executables.
    • [ [examples] ]: This target is useful for libraries to demonstrate the use of external APIs to users through example code. The example source code located in the example directory can be built into executable binaries using this target.
    • [ [test] ]: Files located in the tests directory represent integration tests and each of these can be compiled into a separate executable binary.
    • [ [bench] ]: Benchmark functions defined in libraries and binaries are compiled into separate executables.
  • Specifying dependencies for the package: The source files in a package may depend on other internal or external libraries, which are also called dependencies. Each of these in turn may depend on other libraries and so on. Cargo downloads the list of dependencies specified under this section and links them to the final output targets
    • [dependencies]: Package library or binary dependencies
    • [dev-dependencies]: Dependencies for examples, tests, and benchmarks
    • [build-dependencies]: Dependencies for build scripts (if any are specified)
    • [target]: This is for the cross-compilation of code for various target architectures. Note that this is not to be confused with the output targets of the package, which can be lib, bin, and so on.
  • Specifying build profiles: 4 types of profiles that can be specified while building a cargo package:
    • dev: The cargo build command uses the dev profile by default. Packages built with this option are optimized for compile-time speed.
    • release: The cargo build --release command enables the release profile, which is suitable for production release, and is optimized for runtime speed.
    • test: The cargo test command uses this profile. This is used to build test executables.
    • bench: The cargo bench command creates the benchmark executable, which automatically runs all functions annotated with #[bench] attribute.
  • Specifying the package as a workspace: A workspace is a unit of organization where multiple packages can be grouped together into a project and is useful to save disk space and compilation time when there are shared dependencies across a set of related packages. The [workspace] section can be used to define the list of package that are part of the workspace.

2. Building a static library crate

cargo new --lib lib-program

Add the following code to src/lib.rs

1
2
3
pub fn hello_from_lib(message: &str) {
    println!("Printing Hello {} from library",message);
}

Run cargo build

Create new bin/new_main.rs inside src then add to new_main.rs:

1
2
3
4
5
6
use lib_program::hello_from_lib;

fn main(){
	println!("Going to call library function");
	hello_from_lib("Rust system programmer");
}

Run cargo run --bin new_main

Result:

1
2
Going to call library function
Printing Hello Rust system programmer from library

If you want to place new_main.rs file in another location (instead of within src/bin), then add a target in Cargo.toml and mention the name and path of the binary as shown in the following example, and move new_main.rs file to the specified location:

1
2
3
[[bin]]
name = "new_main"
path = "/src/new_main.rs"

Run cargo run --bin new_main

IV. Cargo dependencies

1. Dependency management

cargo new deps-example && cd deps-example

In Cargo.toml, add:

1
2
[dependencies]
chrono = "0.4.0"
  • Chrono is a datetime library. This is called a dependency because our deps-example crate depends on this external library for its functionality.

  • When you run cargo build, cargo looks for a crate on crates.io with this name and version. If found, it downloads this crate along with all of its dependencies, compiles them all, and updates a file called Cargo.lock with the exact versions of packages downloaded. The Cargo.lock file is a generated file and not meant for editing.

  • Each dependency in Cargo.toml is specified in a new line and takes the format <crate-name> = "<semantic-version-number>". Semantic versioning or Semver has the form X.Y.Z, where X is the major version number, Y is the minor version, and Z is the path version.


Specifying the location of a dependency:

  • crates.io registry: This is the default option and all that is needed is to specify the package name and version string.
  • Alternative registry: While crates.io is the default registry, Cargo provides the option to use an alternate registry. The registry name has to be configured in the .cargo/config file, and in Cargo.toml, an entry is to be made with the registry name, as shown in the example here:
1
2
[dependencies]
cratename = { version = "2.1", registry = "alternate-registry-name" }
  • Git repository: A Git repo can be specified as the dependency:
1
2
[dependencies]
chrono = { git = "https://github.com/chronotope/chrono", branch = "master" }

Cargo will get the repo at the branch and location specified, and look for its Cargo.toml file in order to fetch its dependencies.

  • Specify a local path: Cargo supports path dependencies, which means the library can be a sub-crate within the main cargo package. While building the main cargo package, the sub-crates that have also been specified as dependencies will be built. But dependencies with only a path dependency cannot be uploaded to the crates.io public registry.

  • Multiple locations: Cargo supports the option to specify bot a registry version and either a Git or path location. For local builds, the Git or path version is used, and the registry version will be used when the package is published to crates.io

Using dependent packages in source code

1
2
3
4
5
use chrono::Utc;

fn main() {
    println!("Hello, time now is {:?}", Utc::now());
}
1
2
3
fn main() {
    println!("Hello, time now is {:?}, chrono::Utc::now());
}
1
2
3
4
use chrono as time;
fn main() {
    println!("Hello, time now is {:?}", time::Utc::now());
}

V. Writing and running automated tests

1. Writing unit tests

cargo new test-example && cd test-example

Add to src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::process;

fn main() {
    println!("{}", get_process_id());
}

fn get_process_id() -> u32 {
    process::id()
}

#[test]
fn test_if_process_id_is_returned() {
    assert!(get_process_id() > 0);
}

In order to tell the compiler that this is a test function, use #[test] annotation. The assert! macro (available in standard Rust library) is used to check whether a condition evaluates to true. There are two other macros available, assert_eq! and assert_ne!, which are used to test whether the two arguments passed to these macros are equal or not.

A custom error message can also be specified:

1
2
3
4
#[test]
fn test_if_process_id_is_returned() {
    assert_ne!(get_process_id(), 0, "There is error in code");
}

To compile but not run the tests, use the --no-run option with cargo test command.

In order to provide more modularity and to address the preceding questions, it is idiomatic in Rust to group test functions in a test module:

1
2
3
4
5
6
7
8
#[cfg(test)]
mod tests {
    use super::get_process_id;
    #[test]
    fn test_if_process_id_is_returned() {
        assert_ne!(get_process_id(), 0, "There is error in code");
    }
}

2. Writing integration tests

cargo new --lib integ-test-example

Create tests folder same level with src then add integration_test1.rs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use integ_test_example;

#[test]
fn files_test1() {
 	assert_ne!(integ_test_example::get_process_id(),0,"Error
 in code");
}

#[test]
fn files_test2() {
 	assert_eq!(1+1, 2);
}

#[test]
fn process_test1() {
 	assert!(true);
}

Run cargo test

Ignore test by adding #[ignore]

1
2
3
4
5
#[test]
#[ignore]
fn process_test1() {
    assert!(true);
}

Running tests sequentially or in parallel:

cargo test -- --test-threads=1


V. Document

The following are some aspects of a crate that it would be useful to document:

  • An overall short description of what your Rust library does
  • A list of modules and public functions in the library
  • A list of other items, such as traits, macros, structs, enums and typedefs, that a public user of the library needs to be familiar with to use various features
  • For binary crates, installation instruction and command-line parameters
  • Examples that demostrate to users how to use the crate
  • Optionally, design details for the crate

2 ways to document:

  • Inline documentation comments within the crate
  • Separate markdown files

rustdoc tool will convert them into HTML, CSS, and JavaScript code that can be viewed from a browser.

1. Writing inline documentation comments within crate

Rust has 2 types of comments:

  • code comments: aimed at developers
  • documentation comments: aimed at users of the library/crate

Code comments are written using:

  • // for single-line comments and writing inline documentation comments within crate
  • /* */ for multi-line comments

Documentation comments are written using 2 styles:

  • /// for commenting on individual items that follow the comments. Markdown notation can be used to style the comments (for example, bold or italic). This is typically used for item-level documentation.
  • //! : this is used to add documentation for the item that contains these comments (as opposed to the first style, which is used to comment items that follow the comments). This is typically used for crate-level documentation.

In both cases, rustdoc extracts documentation from the crate’s documentation comments.

Add the following comments to the integ-test-example in lib.rs:

1
2
3
4
5
6
7
8
9
10
11
//! This is a library that contains functions related to
//! dealing with processes,
//! and makes these tasks more convenient.

use std::process;

/// This function gets the process ID of the current
/// executable. It returns a non-zero number
pub fn get_process_id() -> u32 {
    process::id()
}

Run cargo doc --open

2. Writing documentation in markdown files

Create folder doc same level with src then add itest.md file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Docs for integ-test-example crate

This is a project to test `rustdoc`

[Here is a link!](https://www.rust-lang.org)

// Function signature

pub fn get_process_id() -> u32 {}

This function returns the process ID of the currently running executable:

// Example

```rust
use integ_test_example;

fn get_id() -> i32 {
	let my_pid = get_process_id();
	println!("Process id for current process is: {}", my_pid);
}

Run rustdoc doc/itest.md

3. Running documentation tests

Add to src/lib.rs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//! Integration-test-example crate
//!
//! This is a library that contains functions related to
//! dealing with processes
//! , and makes these tasks more convenient.
use std::process;
/// This function gets the process id of the current
/// executable. It returns a non-zero number
/// ```
/// fn get_id() {
/// let x = integ_test_example::get_process_id();
/// println!("{}",x);
/// }
/// ```
pub fn get_process_id() -> u32 {
 process::id()
}

Run cargo test --doc

Alternatively, running cargo test will run all the test cases from the tests directory (except those that are marked as ignored), and then run the documentation tests.

[References]

  • Practical System Programming for Rust Developers - Chapter 1: “Tools of the Trade - Rust Toolchains and Project Structures”
This post is licensed under CC BY 4.0 by the author.