Write a function that adds tasks
The add_task
function needs to append a new Task
value to a possibly existing collection of tasks that's encoded in a JSON file.
So, before inserting a task into that collection, we must first read that file and assemble a vector of tasks from its contents.
The first version looks like this:
use std::fs::{File, OpenOptions};
use std::path::PathBuf;
use std::io::{Result, Seek, SeekFrom};
// ...
pub fn add_task(journal_path: PathBuf, task: Task) -> Result<()> {
// Open the file.
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(journal_path)?;
// Consume the file's contents as a vector of tasks.
let mut tasks: Vec<Task> = match serde_json::from_reader(&file) {
Ok(tasks) => tasks,
Err(e) if e.is_eof() => Vec::new(),
Err(e) => Err(e)?,
};
// Rewind the file after reading from it.
file.seek(SeekFrom::Start(0))?;
// Write the modified task list back into the file.
tasks.push(task);
serde_json::to_writer(file, &tasks)?;
Ok(())
}
Let's go over this function in four steps:
Open the file
First, we open the file by using OpenOptions
, which allows us to specify some modes for operating on the file, like read
, write
, and create
(for when the file doesn't yet exist).
The question mark symbol (?
) after that statement is used to propagate errors without writing too much boilerplate code. It's syntax sugar for early returning an error if that error matches with the return type of the function it's in. So these snippets are equivalent:
fn function_1() -> Result(Success, Failure) {
match operation_that_might_fail() {
Ok(success) => success,
Err(failure) => return Err(failure),
}
}
fn function_2() -> Result(Success, Failure) {
operation_that_might_fail()?
}
That pattern is used a lot in code that needs to do multiple I/O operations, as we do in this program.
Build a reader and consume its contents as a vector of tasks
The second step is to actually read the file. To read the file, serde_json
asks for any type that implements the Reader
trait. The File
type implements that trait, so we just pass it as a parameter to the serde_json.from_reader
function while declaring that we expect to receive a Vec<Task>
from it.
Keep in mind that accessing the file system is an I/O action that can fail for various reasons. So we need to consider how our program should behave (and possibly recover) in some specific cases. For example, serde_json
will return an error when it reaches the end of a file without having found anything to parse. This event will always happen in an empty file, and we need to be able to recover from it.
To recover from specific kinds of errors, we use guards
in the match
expression to build an empty Vec
when the specific error occurs. The Vec
represents an empty to-do list.
Note that serde_json::Error
can easily be converted to the std::io::Error
type because it
implements the From
trait. That makes it possible for us to use the ?
operator to unpack or early return them.
Rewind the file after reading from it
Because we moved the cursor to the end of the file, we need to rewind the file before we write over it again. If we don't rewind the file, we'd begin writing at the cursor's last position, which would cause a malformed JSON file. We use the Seek
trait and the SeekFrom
enum from the std::io
module to rewind the file.
Write the modified task list back into the file
Finally, we push the Task
value received as a function parameter to the task list and use serde_json
to write the task vector into the file. We then return the empty tuple value inside an Ok
to indicate that everything went according to our plans.