Back to the basics - Reading and writing files

Up until now, we've used the keyboard and the screen to input some data and display some values. This is a common form of input and output, or I/O. Another common practice is reading and writing data to files. Storing data in files allows you to persist data over time. This saves the user from inputting th same data over and over again.

In this chapter we'll build upon the main.rs and utils.rs from the previous chapter. We'll ask the user for some input, save it to a file. Then we'll read the content back and print it on the screen. Don't worry, we'll do this in baby steps.

Adding I/O capabilities to our project

At the top of our main.rs file there is this 'use' statement: use crate::utils::ask_for_a_number;. This statement allows us to use the ask_for_a_number function from the utils module. ask_for_a_number is a function you've written yourself. Rust also provides a whole library of functions developed by the Rust community; the standard library.

You can use function from the standard library by adding a use std:: statement to the source file. In our case we'll add this line to main.rs: use std::fs::write;

Adding this line allows us to use the write function from the fs (filesystem) module in the standard library.

Showing documentation for a function

In RustRover you can view instructions on the use of a function, by pressing and holding the "cmd" button on your keyboard and hovering the mouse over the function in the use statement. The function will become underlined, like a link on a web page. You can click the link to open a new tab with the documentation (and the source code of the function). Ignore the source mumbo jumbo and focus on the documentation directly over the function. In the newer versions of RustRover this is rendered in a nicely formatted way.

Exercise

Use the above method to highlight the write function, click on it, and read the documentation. You can clone the fs.rs tab afterwards.

We've seen that write can be used to write contents to a file. Exactly what we need!

The complete Rust standard library is documented here: doc.rust-lang.org

Getting some data from the user.

We'll use the Person structure from the Data structures chapter to hold the user input. Add the code above the main() function, and include an age field:

struct Person {
    first_name: String,
    last_name: String,
    age: u8,
}

Now we'll ask the user to provide some input to fill this structure:

main.rs

use crate::utils::{ask_for_a_number, ask_for_a_string};
use std::fs::write;

mod utils;

struct Person {
    first_name: String,
    last_name: String,
    age: u8,
}

fn main() {
    let first_name = ask_for_a_string("What is your first name?");
    let last_name = ask_for_a_string("What is your last name?");
    let age = ask_for_a_number("How old are you?").unwrap_or(0);
    let person = Person {
        first_name,
        last_name,
        age,
    };
}

For simplicity’s sake I've used the .unwrap_or(0) to fall back to a default age of 0 when the user makes an input error.

Exercise

Run the above code and see if you can input first name, last name and age.

Now we'll write the contents of Person to a file named people.txt. For this we'll create a new function: write_person:

fn write_person(person: &Person) -> std::io::Result<()> {
    let mut output = String::new();
    output.push_str(&person.first_name);
    output.push('\n');
    output.push_str(&person.last_name);
    output.push('\n');
    output.push_str(&person.age.to_string());
    output.push('\n');
    write("people.txt", output)
}

Unfortunately the Person data structure cannot be written to a file as-is. We can however write a String to a file. The write_person function creates a new empty String called output and appends the fields of person to the output.

The push_str method adds the contents of the provided field to output. The push method adds a single character to output.

The '\n' represents the new line character. By pushing this to the output, we effectively add an "Enter" after the field's content has been added. We do this so that we can read the file later in an easy way.

Finally we use the write function to write the output to the people.txt file.

The std::io::Result<()> that we are returning, is actually the output of the write function. We are returning this transparently to the main function. The std::io::Result Is similar to the Result type we've explored earlier. It either holds an Ok(()) or an std::io::Error. We can use the Error type to notify the user if something bad happened when writing the people.txt file to the hard drive.

The () in the std::io::Result<()> is Rust speak for "nothing". So this means that in the positive case, when we return Ok(()), we are actually returning nothing to the calling function, other than the fact that the operation was a success.

Now that we've created the write_person function, we can add it to the bottom of the main() function, immediately after creating the person variable:

write_person(&person);

Exercise

Run the program, input the needed fields and wait for the program to finish. Now check the top-level project directory and look for the people.txt file. Open it by double-clicking. Explore the contents.

If all went well, you should have a people.txt file that holds the data you've inputted. Pretty neat, don't you think?

Exercise

Use the instructions from the previous chapter to commit your code. Use the message: "write Person to disk", or something similar.

Checking for errors

If your program compiled, and ran, but the people.txt file was not created. This chapter is for you!

We've seen that the write function can possibly return an Error as part of the Result. We've ignored this error for now. Let's add some code to the main.rs to write a message to the user in case of a failure.

Exercise

Add a match block for the call to write_person(&person) in the main() function. Do this by replacing the write_person(&person); line with:

   match write_person(&person) {
       
   }

Notice the red line under match. Click on it and wait for the red light-bulb to appear. Click on the light-bulb and select "Add remaining patterns". See what happens.

I've added some feedback to the match block. main.rs now looks like this:

use crate::utils::{ask_for_a_number, ask_for_a_string};
use std::fs::write;

mod utils;

struct Person {
    first_name: String,
    last_name: String,
    age: u8,
}

fn main() {
    let first_name = ask_for_a_string("What is your first name?");
    let last_name = ask_for_a_string("What is your last name?");
    let age = ask_for_a_number("How old are you?").unwrap_or(0);
    let person = Person {
        first_name,
        last_name,
        age,
    };

    match write_person(&person) {
        Ok(_) => println!("people.txt was written successfully"),
        Err(err) => println!("There was error while writing people.txt: {}", err),
    }
}

fn write_person(person: &Person) -> std::io::Result<()> {
    let mut output = String::new();
    output.push_str(&person.first_name);
    output.push('\n');
    output.push_str(&person.last_name);
    output.push('\n');
    output.push_str(&person.age.to_string());
    output.push('\n');
    write("people.txt", output)
}

The _ in the Ok(_) statement means that we want to ignore the data that was wrapped in the Ok(). As seen before the write_person function is returning () in an Ok which means: "nothing". So there is no need for us to create a variable for this. It is empty anyway.

Exercise

Run the program again and watch for any errors.

Reading the data

You can take a break here if you want. Make sure to commit your changes, before you leave!

Now that we've written the people.txt file we'll use a new function to read the Person back from the file. You've guessed it... read_person.

To read something from a file, we need to use a new function from the standard library: read_to_string. Replace the use std::fs::write line at the top of main.rs with this:

use std::fs::{write, read_to_string};

Now add the read_person function:

fn read_person() -> Result<Person, std::io::Error> {
    let input = read_to_string("people.txt")?;
    let mut lines = input.split('\n');
    let first_name = lines.next().unwrap_or("").to_string();
    let last_name = lines.next().unwrap_or("").to_string();
    let age_as_string = lines.next().unwrap_or("0").to_string();
    let age = u8::from_str(&age_as_string).unwrap_or(0);
    let person = Person {
        first_name,
        last_name,
        age: age,
    };
    Ok(person)
}

Exercise

Notice that the from_str is highlighted in red. Use the same "light-bulb" method from before, to " Import" the missing 'use' statement.

The read_person function does exactly the opposite to the write_person function.

It starts with the read_to_string("people.txt") to read the contents of the people.txt file to the input variable.

Notice the ? at the end of the line. The ? is a super powerful feature of Rust. It will check the result of the preceding method. If there is an error, it will exit out of the function, retuning the error. Otherwise, it will assign the value in the Ok to the variable. So the ? is like a match block in disguise.

We know for sure that input will hold the contents of the people.txt file. In case of an error, due to the ?, the read_person function would have already stopped and returned the error.

Next we use the split('\n') method on input.

Do you recognize the \n character? This matches up with the separator we've used when creating the people.txt file. Because we were so clever before, we can now easily re-create the input fields with the split method.

The next three lines read the split values, using an empty string """ in case something went wrong.

Recall the use of unwrap_or from the earlier chapter.

Now our age field needs a special treatment. We read the age_as_string which holds the text-version of the age field. We use the same from_str associated function, from before, to convert the String to a u8.

Finally, we create a new Person structure from these fields, and return this wrapped in an Ok.

Let's add the read_person function to main() and see if it works. The main.rs should look like this:

use crate::utils::{ask_for_a_number, ask_for_a_string};
use std::fs::{read_to_string, write};
use std::str::FromStr;

mod utils;

struct Person {
    first_name: String,
    last_name: String,
    age: u8,
}

fn main() {
    let first_name = ask_for_a_string("What is your first name?");
    let last_name = ask_for_a_string("What is your last name?");
    let age = ask_for_a_number("How old are you?").unwrap_or(0);
    let person = Person {
        first_name,
        last_name,
        age,
    };

    match write_person(&person) {
        Ok(_) => println!("people.txt was written successfully"),
        Err(err) => println!("There was error while writing people.txt: {}", err),
    }

    match read_person() {
        Ok(person) => println!("people.txt was read successfully"),
        Err(err) => println!("There was error while reading people.txt: {}", err),
    }
}

fn write_person(person: &Person) -> std::io::Result<()> {
    let mut output = String::new();
    output.push_str(&person.first_name);
    output.push('\n');
    output.push_str(&person.last_name);
    output.push('\n');
    output.push_str(&person.age.to_string());
    output.push('\n');
    write("people.txt", output)
}

fn read_person() -> Result<Person, std::io::Error> {
    let input = read_to_string("people.txt")?;
    let mut lines = input.split('\n');
    let first_name = lines.next().unwrap_or("").to_string();
    let last_name = lines.next().unwrap_or("").to_string();
    let age_as_string = lines.next().unwrap_or("0").to_string();
    let age = u8::from_str(&age_as_string).unwrap_or(0);
    let person = Person {
        first_name,
        last_name,
        age: age,
    };
    Ok(person)
}

Printing the Person

There is one thing left to do; print the Person back to the user.

Exercise

Use the knowledge from the previous chapters to add a new print() method to Person that prints the three fields back to the user.

Modify the last match block to look like this:

    match read_person() {
        Ok(person) => {
            println!("people.txt was read successfully:");
            person.print();
        },
        Err(err) => println!("There was error while reading people.txt: {}", err),
    }

Run the program and see how your print method works.

Congratulations! Another major milestone achieved!

Cleaning up

Now that our program is functional, let's take some time to clean it up. We'll start by creating an associated function to construct the Person:

impl Person {
    fn new() -> Self {
        let first_name = ask_for_a_string("What is your first name?");
        let last_name = ask_for_a_string("What is your last name?");
        let age = ask_for_a_number("How old are you?").unwrap_or(0);
        Person {
            first_name,
            last_name,
            age,
        }
    }
}

I'd also like to change write_person and read_person into write_people and read_people:

The write_people function is a pretty straightforward change from write_person:

fn write_people(people: Vec<Person>) -> std::io::Result<()> {
    let mut output = String::new();

    for person in people {
        output.push_str(&person.first_name);
        output.push('\n');
        output.push_str(&person.last_name);
        output.push('\n');
        output.push_str(&person.age.to_string());
        output.push('\n');
    }

    write("people.txt", output)
}

For read_people I've split the code between read_person and read_people:

fn read_person(lines: &mut Split<char>) -> Option<Person> {
    let first_name = lines.next()?;
    let last_name = lines.next()?;
    let age_as_string = lines.next()?;
    let age = u8::from_str(&age_as_string).unwrap_or(0);
    let person = Person {
        first_name: first_name.to_string(),
        last_name: last_name.to_string(),
        age,
    };
    Some(person)
}

fn read_people() -> Result<Vec<Person>, std::io::Error> {
    let input = read_to_string("people.txt")?;
    let mut lines = input.split('\n');
    let mut people = vec![];
    while let Some(person) = read_person(&mut lines) {
        people.push(person)
    }
    Ok(people)
}

The read_people is the function that will be called from main. It now returns a list of Person, a Vec<Person>. The while let is similar to the if let, we've seen in previous chapters, but while let loops like for until the condition becomes false. In this case, it will add a person to the people vector, until the read_person function returns None.

I've modified the read_person function, and added a few question marks (?). The ? ensures that None is returned when there is no more data that can be read. This typically means, we've reached the end of the file.

The main function is adjusted to use the new functions:

fn main() {
    let person = Person::new();
    match write_people(vec![person]) {
        Ok(_) => println!("people.txt was written successfully"),
        Err(err) => println!("There was error while writing people.txt: {}", err),
    }

    match read_people() {
        Ok(_people) => println!("people.txt was read successfully"),
        Err(err) => println!("There was error while reading people.txt: {}", err),
    }
}

Exercise

Now that all the needed functions are defined, please move all functions, except main to the utils.rs. Also move the struct Person and impl Person. Use pub to make functions, fields and structs, accessible by main.rs. Make sure the code compiles. You can have clippy check this for you.

Exercise

If you want to make your code really nice, create a new db.rs file, and more the people-related code in that file, rather than into utils.rs.

Validate that your code matches with: this.

Now that the project is nice and tidy, we're ready to move to the next chapter!