Create the tasks module
The tasks
module will represent our tasks, and how we will save and access them.
Create a new file in the src directory named tasks.rs
. Inside of that file, we'll start by defining a simple struct to represent what a to-do item will look like in our program:
use chrono::{DateTime, Utc};
#[derive(Debug)]
pub struct Task {
pub text: String,
pub created_at: DateTime<Utc>,
}
Our struct has two fields:
text
stores the task description, like"pay the bills"
.created_at
stores the timestamp of the task's creation.
We won't add a status
or is_complete
field because we'll represent the to-do list as a vector of tasks (Vec<Task>
). So when a task is complete, we can just remove it from the vector.
You might have noticed that we're using a third-party crate, chrono
. We've specified the Utc
parameter for its DateTime
struct. chrono
is a good crate to use if you need to
handle date and time data in Rust. It provides an easy API for representing a moment in time.
Because we're using it, we need to declare it in the Cargo.toml
file:
[dependencies]
chrono = "0.4"
structopt = "0.3"
The next step is to implement a method for instantiating new tasks. Tasks will always be timestamped with the current date and time. Add the following code after the Task
struct:
impl Task {
pub fn new(text: String) -> Task {
let created_at: DateTime<Utc> = Utc::now();
Task { text, created_at }
}
}
This code defines the Task::new
function. The function requires only the task description. It will capture the current timestamp by using the Utc::now()
method.
It seems that our task struct is complete. Now let's tackle this module's next item: persistence.
Because we'll represent our to-do list as a vector of tasks, we could easily use a JSON file to persist the data. To achieve that, the best course of action is to use another excellent crate from the Rust ecosystem: serde_json
.
Serialize and deserialize tasks by using serde_json
Before we continue, we should cover some recommended practices for encoding and decoding in Rust.
When we need to persist structs and enum instances, we need to think about serialization. When we need to get that data back into a program, we’re talking about deserialization.
Serialization and deserialization are the processes of storing data in a stream of bytes and then retrieving it for later use, without any loss of information. You can then send those bytes over a connection or store them in a file in a storage device. You can learn more about serialization and deserialization from this OWASP cheatsheet.
The Rust community recommends the serde
crate for handling most serialization and deserialization of Rust data structures efficiently and generically, ours included. By using this preexisting crate, we can be even more productive and idiomatic.
To get started with serializing our Task
type, we'll need two crates:
serde
. The base crate that will enable our types to derive theSerialize
andDeserialize
traits.serde_json
. The crate that will implement those traits into our chosen file specification format, JSON.
As always, the first step is to include serde_json
and serde
in the [dependencies]
section of our Cargo.toml
file. This time we're going to use a different notation to specify them because we'll need to conditionally compile some serde
features. Your file should now look like this:
[dependencies]
serde_json = "1.0" # Add serde_json.
structopt = "0.3"
[dependencies.chrono]
features = ["serde"] # We're also going to need the serde feature for the chrono crate, so we can serialize the DateTime field.
version = "0.4"
[dependencies.serde] # Add serde in its own section.
features = ["derive"] # We'll need the derive feature.
version = "1.0"
We should now be able to adapt the Task
struct to use the new features from serde
. Open the tasks.rs
file and modify the struct so it looks like this:
use chrono::{serde::ts_seconds, DateTime, Local, Utc};
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Deserialize, Serialize)]
pub struct Task {
pub text: String,
#[serde(with = "ts_seconds")]
pub created_at: DateTime<Utc>,
}
Note the differences:
- We added
Deserialize
andSerialize
to our list of traits to implement. - We annotated the
created_at
field, passingts_seconds
fromchrono
to theserde(with = ...)
attribute sochrono
can informserde
how itsDatetime
type will implement the two new traits.
Now that our Task
type can do both serialization and deserialization, we can move on and implement our file handling functions.
Interact with the file system
Let's review the three kinds of actions our program needs to perform:
- Add new tasks to a to-do list.
- Remove completed tasks from that list.
- Print all the current tasks in the list.
Our module interface should be as simple as that list, so we're going to have three functions, one for each action:
use std::io::Result;
use std::path::PathBuf;
pub fn add_task(journal_path: PathBuf, task: Task) -> Result<()> { ... }
pub fn complete_task(journal_path: PathBuf, task_position: usize) -> Result<()> { ... }
pub fn list_tasks(journal_path: PathBuf) -> Result<()> { ... }
First, take a look at each function's signature. Notice that all of them require a journal_path: PathBuf
argument. That's because all of them need a file path to complete their work: the path to the file where the tasks will be stored.
add_task
also requires aTask
argument. That argument specifies the task that will be added to the list.complete_task
requires atask_position
argument to indicate whichTask
will be removed. When a task is removed, that means it's completed.list_tasks
doesn't need any additional information. It will just present to the user all tasks currently stored in the journal file, in a pretty format.
The functions all have the same return type: std::io::Result<()>
. This format indicates that the return type is an I/O result. This return type signals that we're expecting a broad family of unwanted outcomes that might arise when we deal with data in the physical word. The Ok
variant is just an empty tuple, ()
, which is the type commonly associated with no data at all. Its only purpose is to signal that the function returned an Ok
and no errors occurred.
In the next three units, we'll walk through writing the contents of each function in detail.