Building a command-line todo app in Rust

In this tutorial, we are going to create a simple command-line todo app. By the end of this tutorial, you should have a basic understanding of Rust programming language, building command-line apps in Rust, and performing file-system operations in Rust.

Installation

Rust community has created a beautiful installation experience for everyone using rustup toolchain manager. If you are on Unix based systems, we can run the following command in our terminal and then follow the onscreen instructions:

$ curl https://sh.rustup.rs -sSf | sh

We’ll be using nightly Rust for current tutorial. To install nightly Rust, run the following command:

$ rustup install nightly

To set default toolchain to nightly, run the following command:

$ rustup default nightly

Besides from Rust compiler and standard library, rustup will also install Rust’s package manager cargo.

Boot up

To start a new project with cargo, run the following command:

$ cargo new todo

This will generate a new project named todo. Let’s check out what cargo has generated for us:

$ cd todo
$ tree .
.
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files

Cargo has generated two files for us, Cargo.toml and src/main.rs. Cargo.toml is a manifest file, and it contains all of the metadata that Cargo needs to compile your project. src/main.rs contains following code:

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

To compile you project, run the following command in your terminal:

$ cargo build

This will create an executable ./target/debug/todo.

$ ./todo/debug/todo
Hello, world!

Alternatively, we can also run our project using the following command:

$ cargo run
NOTE: If the project is not already compiled, cargo run will also compile the project before running it.

Package Registry

crates.io is the Rust community’s central package registry that serves as a location to discover and download packages. cargo is configured to use it by default to find requested packages.

NOTE: Rust community likes to call packages as crates.

We can add a dependency in our project by adding crate name and version in [dependencies] section of Cargo.toml.

For our current todo app, we’ll need following crates in our project:

  1. structopt: Package used to parse command line arguments.
  2. serde: A framework for serialising and deserialising Rust data structures efficiently and generically.
  3. serde_derive: This package provides macros for automatically generating implementations for serialisation and deserialisation for most of Rust data structures.
  4. bincode: A compact encoder / decoder pair that uses a binary zero-fluff encoding scheme.
  5. prettytable-rs: A library for printing nicely formatted and aligned tables in terminal.

If the descriptions of above crates does not ring a bell, don’t panic, we’ll go through each on of them in detail.

For now, let us concentrate on adding these dependencies in our Cargo.toml. Add the following lines in [dependencies] section of Cargo.toml.

[dependencies]
structopt = "0.2"
serde = "1.0"
serde_derive = "1.0"
bincode = "1.0"
prettytable-rs = "0.8"
NOTE: The versions mentioned above are the latest versions at the time of writing this blog. You can check latest released versions on crates.io.

Now, our Cargo.toml looks like this:

[package]
name = "todo"
version = "0.1.0"
authors = ["Your Name <email@you.com>"]
edition = "2018"
[dependencies]
structopt = "0.2"
bincode = "1.0"
serde = "1.0"
serde_derive = "1.0"
prettytable-rs = "0.8"
NOTE: If your Cargo.toml does not contain edition = "2018", add it. Here is the Rust 2018 edition guide which explains the changes and migration strategy from Rust 2015.

We can run cargo build to compile our project with all the added dependencies.

Todo app overview

Before delving into our implementation of todo app, let us first decide how the command line interface will look like for our app.

Add a todo

To add a todo, we’ll run the following command:

$ todo add "Learn Rust"

List todos

To list all the pending todos, we’ll run the following command:

$ todo list
 ID | Todo
----+------------
1 | Learn Rust

todo list will also support three subcommands:

  1. pending
  2. completed
  3. all

To list all the completed todos, we’ll run the following command:

$ todo list completed

And, to list all the todos (pending and completed), we’ll run the following command:

$ todo list all

Marking todos as done

To mark a todo as done, we’ll run the following command:

$ todo done 1

Here, 1 is the ID of the todo we want to mark as done.

Custom data types

struct and enum are two constructs in Rust used to define custom data types. If you’re familiar with an object-oriented language, a struct is like an object’s data attributes. Rust’s enum, unlike other object-oriented languages, is more powerful and is most similar to algebraic data types in functional languages.

To define a struct, we enter the keyword struct and name the entire struct. Then, inside curly brackets, we define the names and types of the pieces of data, which we call fields.

struct User {
id: i32,
name: String,
email: String,
active: bool,
}

To define an enum, we enter the keyword enum followed by its name. Then, inside curly brackets, we specify all the possible enumerations. In Rust, each enumeration can be of different variant.

enum Message {
Quit,
Move { x: i32, y: i32 }
Write(String)
ChangeColour(i32, i32, i32)
}

This enum has four variants with different types:

  • Quit has no data associated with it at all.
  • Move includes an anonymous struct inside it.
  • Write includes a single String.
  • ChangeColor includes three i32 values.

Parsing command-line arguments

To parse command-line arguments passed by the user, we’ll be using structopt crate. structopt exposes a functionality of parsing command-line arguments by creating a struct/enum.

Create a new file src/command.rs.

Here, we’re defining our todo command with three sub-commands, add, list, and done.

  • add takes a String as an input.
  • done takes an u64 as an input.
  • list takes an optional sub-command defined in ListCommand which has three variants, pending, completed, and all.

Now, modify src/main.rs to print the parsed Command.

Now, run the following command in terminal:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/todo --help`
todo 0.1.0
Your Name <email@you.com>
A simple command line todo app
USAGE:
todo <SUBCOMMAND>
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
SUBCOMMANDS:
add Add a todo
done Mark todo as done
help Prints help message
list List todos

As we can see, structopt has done a lot of work for us by creating a wonderful command-line interface and added subcommands for help and version.

Now, let us run following command in terminal:

$ cargo run add "Learn Rust"
    Finished dev [unoptimized + debuginfo] target(s) in 0.65s
Running `target/debug/todo add 'Learn Rust'`
Add { name: "Learn Rust" }

structopt parses command-line arguments for us and gives us an object of Command type, which, in this example, is of variant Add with name Learn Rust.

NOTE: To know more about structopt, read the docs.

Performing file-system operations

To perform file-system operations, we’ll need help from two modules in Rust’s standard library.

  • std::io
  • std::fs

Before digging into these modules, let us first understand the way our todo app will work.

  • Every todo has an ID and a name.
  • Whenever user adds a new todo, we write it in todos/pending.todo file. This file contains vector of all the pending todos encoded using bincode.
  • We also have a file todos/counter.todo which contains last used ID. To assign a unique ID to a new todo, we increment the value stored in todos/counter.todo and assign it to new todo.
  • Whenever user marks a todo as done, we remove that todo from todos/pending.todo and write it into todos/completed.todo which contains vector of all the completed todos.
NOTE: For simplicity, we’re not using any database to store our todos.

Creating directories

To create new directories, we can use create_dir_all function in std::fs module which recursively creates a directory and all of its parent components if they are missing.

Opening a file

To open/create a file, we can use OpenOptions struct in std::fs. OpenOptions exposes the ability to configure how a File is opened and what operations are permitted on the open file.

  • Opening a file to read:
use std::fs::OpenOptions;
let file = OpenOptions::new().read(true).open("foo.txt");
  • Opening a file for both reading and writing, as well as creating it if it doesn’t exist:
use std::fs::OpenOptions;
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open("foo.txt");

IO operations on a file

Every File instance can be read/written depending on what options it was opened with.

  • To write data into a file:
use std::fs::File;
use std::io::{Write, Result};
fn main() -> Result<()> {
let mut file = File::create("foo.txt")?;
file.write_all(b"Hello, world!")?;

Ok(())
}
NOTE: File::create and file.write_all functions return Result instance which can also contain error? operator is used to propagate errors and continue normally when there is no error.
  • To read the contents of a file into a String:
use std::fs::File;
use std::io::{Read, Result};
fn main() -> Result<()> {
let mut file = File::open("foo.txt")?;
let mut contents = String::new();

file.read_to_string(&mut contents)?;
assert_eq!(contents, "Hello, world!");

Ok(())
}

Creating todo service

All the above mentioned tasks will be performed by TodoService. Create a new file src/service.rs.

In this file, we’ve defined five public functions add, done, list_pending, list_completed, and list_all.

  • add function increments the latest counter in todos/counter.todo and writes new todo data in todos/pending.todo.
  • done function deletes the current todo from todos/pending.todo and appends it into todos/completed.todo.
  • list_pending and list_completed function prints all todos in todos/pending.todo and todos/completed.todo respectively.
  • list_all reads todos from both todos/pending.todo and todos/completed.todo and prints them.
NOTE: For printing aligned tables in terminal, we’re using prettytable-rs crate.
NOTE: Before writing any data in file, we’re also encoding that data into binary format with the help of bincode crate.

Command handler

Now that we have our service.rs and command.rs ready, we only need a handler which will understand the command and call appropriate function from service module.

Create a new file src/handler.rs.

Here, we are matching all the possibilities for a command and calling appropriate service functions for each possibility.

Now, modify src/main.rs to call handle function.

NOTE: Also add module declarations (mod command, mod service, and mod handler) in src/main.rs.

Code

The whole code for this tutorial is available here.

Further learning

If you liked this blog and are interested in learning more about Rust Programming Language, you can check The Rust Book and Rust Standard Library Documentation.

Thank you very much for reading until here. ❤