Back to the basics - structuring code

I could have also called this chapter, "cleanup my project." Now that our application is growing, it would be unwise to continue adding all functions to tha main.rs file. At some point, it would become too big to work on in an effective way.

In this chapter, we'll split the code from the previous chapter into to files:

  • main.rs
  • utils.rs

Both these files reside under src within our project. Let's first create the new 'utils.rs' file. Follow these steps:

  1. Select the src folder in the project tree on the left
  2. Then click "File" -> "New" -> "Rust File"
  3. Enter utils as the file name (without .rs)

There should be a banner at the top of the empty utils.rs file: "File is not included in module tree..." Click on the "Attach file to main.rs" link in the banner.

Your main.rs should now look like this:

mod utils;

use std::str::FromStr;

fn main() {
    println!("How old are you?");
    match read_number() {
        Ok(age) => println!("You are {} years old.", age),
        Err(err) => println!("{}", err),
    }
}

fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    input.trim().to_string()
}

fn read_number() -> Result<u8, String> {
    let input = read_string();

    if input.is_empty() {
        Err("You did not enter any data".to_string())
    } else {
        u8::from_str(&input).or(Err("You've entered an invalid number".to_string()))
    }
}

At the top of main.rs the mod utils line is added.

Rust code is organized in so-called "modules". In its simplest form each Rust file under src is a module. You need to explicitly add the modules you need in your program with the mod statement. Otherwise, the functions in that module are unavailable.

Now we'll start the cleanup. Cut & paste the two functions read_string() and read_number() in their entirety to the utils.rs file. Do the same with the use std::str::FromStr; line.

By default, functions in another module are private to that module. This means they can be used within that module by other functions, but they can not be used in other files. Because we want to use both read_string() and read_number() in (future) programs we need to make them publicly available. You do this by adding pub in front of the fn.

When done, your files look like this:

utils.rs

use std::str::FromStr;

pub fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    input.trim().to_string()
}

pub fn read_number() -> Result<u8, String> {
    let input = read_string();

    if input.is_empty() {
        Err("You did not enter any data".to_string())
    } else {
        u8::from_str(&input).or(Err("You've entered an invalid number".to_string()))
    }
}

main.rs

mod utils;

fn main() {
    println!("How old are you?");
    match read_number() {
        Ok(age) => println!("You are {} years old.", age),
        Err(err) => println!("{}", err),
    }
}

Exercise

Run clippy and see if there are any errors. If there is an error implement the suggestion that Clippy makes.

Did you notice that there is a click-able link embedded in the error that takes you directly to the source of the problem?

When done, your main.rs file looks like this:

use crate::utils::read_number;

mod utils;

fn main() {
    println!("How old are you?");
    match read_number() {
        Ok(age) => println!("You are {} years old.", age),
        Err(err) => println!("{}", err),
    }
}

Exercise

Confirm that the code runs and that it behaves the same as before.

Now that we have cleaned up the project, I would like to make one final change. Rather than printing the question in main(), before reading the input, I'd like to have two new functions in utils.rs:

  • ask_for_a_string(question: &str) -> String
  • ask_for_a_number(question: &str) -> Result<u8, String>

These functions should wrap around the existing read_string() and read_number() functions, and print the question, before capturing the result.

Exercise

Implement these two functions in utils.rs. Use the ask_for_a_number() function in main(). Fix the compiler errors until the program runs again. Ignore any warning for now if you please. You can check the spoiler if you get stuck.

Exercise

Review main.rs and (hopefully) conclude that the code became clearer, i.e. the main() function is less noisy and describes better what it is actually doing.

Version control

This is also a good moment to introduce the concept of version control. All modern development environments come with an integrated Version Control System (VCS). RustRover has built-in support for Git (amongst others). Git is the standard VCS that Rust uses. If your project files do not light up in red or green, you may need to install Git on your PC: Installing Git. By default, the main.rs should be colored red.

What does a VCS do, and why would you need a VCS?

The VCS keeps track of the changes you make to the code of your program. Once you have completed a set of changes, you can commit those changes to the VCS. It is like taking a snapshot of your code at that moment. The VCS allows you to roll back errors to a previous commit, or compare your current code to a previous commit. This is very useful as your program grows. The VCS also provides a kind of "reset" option to undo any changes if you get stuck and your code won't compile or work anymore, this is called rollback.

The good thing is that initializing a new Rust project with the cargo init command will already initialize the Git VCS system for your project. Unfortunately, it's not yet "tuned" for use with the RustRover editor. There is a file called .gitignore in the top-level directory of the project. You can open it by double-clicking on it. By default, it looks like this:

/target

The .gitignore file contains a list of files and directories that should be excluded from version control. The target directory is excluded because this is where the compiled project code goes. Including it would make the version control database unnecessarily large without adding any value.

RustRover stores a number of project configuration files in a directory called .idea. You most likely would like to exclude that directory from the version control as well. You do this by adding the .idea line to the .gitignore file:

/target
.idea

Now save the file. The final step is to select the project (name) at the top of the project tree on the left. This is the one in bold. Now open the Git menu from the main menu at the top. Select "Selected Directory", and click on "+ Add".

If you are on an older version of RustRover, this menu may be called "VCS". In that case, look for similar menu options, but it would be better to upgrade to the latest release of the RustRover editor.

After this step, your project files should be colored green. You're all set!

Committing files to Git

I use this four-step approach to commit my files:

  1. Select the files: Select the top level project in the project tree on the left, and open the Git menu from the main menu at the top. Select "Selected Directory", and click on "Commit Directory..."
  2. Check the changes: Verify that the changed files show up in the top window and are marked with a checkbox.
  3. Comment: Provide a meaningful comment to describe what you have changed since the previous commit. If this is the first time you are committing code, write that: "Initial commit".
  4. Commit then click on the "Commit" button

After committing, the change list at the top should be empty.

You can use the "Project" tab at the far left of the screen to go back to the project tree view.

I'll point out later on when it is a good time to commit any changes. We'll also explore some of the features of Git, like checking the history of a file.

At least now we have a good starting point with our cleaned-up project in Git!