A Rust to-do list CLI app - Part 1
By Leonardo Giordani -
This blog was born as a place where I could share what I discovered while I was learning new technologies and concepts. Remaining faithful to this manifesto, I decided to start writing some posts about Rust, as I recently joined the vibrant community behind this language. My roots are in C and Assembly, so I feel at home with Rust that looks to me like a proper modern version of C. As such, I'm supremely interested in the ideas behind it, and in particular I want to focus on low-level data representation, structures, and memory usage.
After having read the manual and implemented several snippets, I decided to try to implement a more complete application and to annotate the journey here. While I was looking for a tutorial I found this useful post by Claudio Restifo, where he develops a simple to-do list management application.
So, I decided to implement the same using Claudio's solution when I was stuck or to compare his strategy with mine, as it is always extremely useful to see how another coder tackles certain challenges. Thanks Claudio! However, as I'm a big fan of TDD, I'd like to follow that approach, which is something that Claudio doesn't do in his post.
Please keep in mind that these are my first steps with the language, so consider what you read here as the work of a beginner (as I am, with this language). I'm more than happy to receive advice or corrections, so feel free to get in touch if you see anything that can be done in a better way. In the post, you will find annotations that highlight the major topics that I think a Rust programmer should be familiar with.
Requirements¶
The requirements I set for the application are:
- Manage a list of entries. Each entry can be in state "to be done" or "done".
- Provide commands to view, add, delete and mark items as "done" or "to be done".
- Can save and retrieve data from a file. The file has a default name that can be changed with an option.
This is an extremely basic application, so the command line is: todo [OPTIONS] COMMAND [KEY]
.
I expect the interaction with the tool to be something like
$ todo list
# TO DO
* Write post
* Buy milk
* Have fun
# DONE
* Feed the cat
$ todo add "Update CV"
$ todo mark-done "Buy milk"
$ todo list
# TO DO
* Write post
* Have fun
* Update CV
# DONE
* Feed the cat
* Buy milk
Initial setup¶
Starting a new Rust project is extremely simple with Cargo:
$ cargo new todo-cli
This will create the required structure in a new directory and create two files: Cargo.toml
and src/main.rs
. The latter will contain some placeholder code that we can use to check our setup
main.rs
fn main() {
println!("Hello, world!");
}
We can run the code with cargo run
or build it into a stand-alone executable with cargo build
. This will compile the code in debug mode by default and put the executable in the directory target/debug
. Cargo can be used to run tests as well (cargo test
).
Cargo is the Rust package manager and the default solution to manage dependencies, compile packages and in general to manage your code. It's highly recommended to learn at least the basics of this powerful tool.
CLI management¶
Command line interfaces are typically not part of the classic TDD cycle, as they should be part of integration tests. Now, the definition that the Rust community uses for integration tests is
Integration tests are external to your crate and use only its public interface in the same way any other code would. Their purpose is to test that many parts of your library work correctly together.
So, the integration they consider here is that between multiple parts of a library. What I am referring to here is more properly system integration tests, where we test the public interface of a whole tool. Long story short, I will not write tests for the CLI commands.
In the aforementioned post, Claudio Restifo suggests we can read command line arguments using std::env::args()
directly with something like
fn main() {
let action = std::env::args().nth(1)
.expect("Please specify an action");
let item = std::env::args().nth(2)
.expect("Please specify an item");
println!("{:?}, {:?}", action, item);
}
In Rust a module can be used directly as long as it is part of the current project. The standard library is clearly visible by default, while other modules have to be declared in the file Cargo.toml
. It is then perfectly acceptable to write let action = std::env::args()...
.
Use declarations, however, can import other modules into the current namespace, to make the code more readable.
The method nth
[docs] returns (not too surprisingly) the nth element of an iterator.
The Rust documentation contains a very useful section on iterators [docs].
The function std::env::args
[docs] (used to access the command line arguments in the traditional Unix fashion) returns Args
[docs], which implements the trait Iterator
.
As it happens in object-oriented programming languages (which Rust is not), the expression "implements an interface" is often simplified to "is". So, colloquially speaking, we can say that std::env::Args
is an Iterator
[docs].
The prototype of nth
is
fn nth(&mut self, n: usize) -> Option<Self::Item>
and it mentions Option<Self::Item>
as the return type. The type Option
provides a method expect
[docs] that returns either the content of the Some
value or panics, printing the given message in the backtrace.
Running the code above with cargo run
produces the following output, where we can see the message set by the first call to expect
.
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/todo-cli`
thread 'main' panicked at src/main.rs:80:42:
Please specify an action
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Better CLI management with Clap¶
Clap stands for Command Line Argument Parser and is a nice crate that simplifies the creation of advanced command line interfaces. I installed it using
$ cargo add clap --features derive
as detailed in the documentation and my code is now
use clap::Parser;
#[derive(Parser)]
struct Cli {
command: String,
key: String,
}
fn main() {
let args = Cli::parse();
println!("Command line: {} {}", args.command, args.key);
}
Clap allows me to add long and short options as well, so later I will use it to specify the database file name. For now, however, this is enough.
The attribute derive
[docs] is another cornerstone of the language and is used everywhere. The machinery behind it is not trivial, but I recommend getting used to the syntax and the standard use cases.
A simple list of elements¶
From Claudio's post I got the idea of using a hash map for the list of items. That's a simple and effective solution, in particular given the fact that Rust provides the collection type out of the box.
As I want to use TDD, I begin with a test. In Rust, we put tests and code in the same file (but for integration tests between modules), so I can write a simple test at the bottom of the file to check that a TodoList
type exists and can be initialised.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_todo() {
let todo = TodoList::new();
}
}
TDD is one of my favourite methodologies and I'm happy to see that Rust allows me to follow it. I can't recommend TDD enough! The Rust book contains a pretty detailed chapter on how to write tests.
Clearly, when I run cargo test
I get a compile error. Let's implement the type then
use std::collections::HashMap;
[...]
struct TodoList {
// true = to do, false = done
items: HashMap<String, bool>,
}
As you see, I had to write a comment as a reminder of the meaning of the boolean values. I also suspect that I will need to use the type HashMap<String, bool>
multiple times, so I will probably end up creating a type alias of some sort.
To initialise such structure I have to create an implementation of the function new
impl TodoList {
fn new() -> TodoList {
let items: HashMap<String, bool> =
HashMap::<String, bool>::new();
TodoList { items: items }
}
}
Rust is not an object-oriented programming language, so it uses plain structs to encapsulate data. The Rust book has a full chapter on struct
and impl
.
Thanks to type inference, the explicit definition of types after the call to HashMap
is not needed and I can write
let items: HashMap<String, bool> = HashMap::new();
or
let items = HashMap::<String, bool>::new();
For such a simple initialisation, I might also write directly
TodoList {
items: HashMap::<String, bool>::new(),
}
However, I will soon replace the ::new()
with something more complicated that reads a file, so I decided to keep version 2. This code passes the test I wrote, so it's good enough for now.
At this point I can also initialise the list in the main function
struct TodoList {
// true = to do, false = done
items: HashMap<String, bool>,
}
impl TodoList {
fn new() -> TodoList {
let items: HashMap<String, bool> = HashMap::new();
TodoList { items: items }
}
}
fn main() {
let args = Cli::parse();
let todo = TodoList::new();
println!("Command line: {} {}", args.command, args.key);
}
Please note that I'm not being too strict with dead code here and the compile will complain about unused variables and fields. I like this, and I won't add underscores to silence the warnings since they are a good reminder of what I still have to implement.
Adding items¶
A good improvement at this point would be to create a method to add items to the list. First, the mandatory test
#[test]
fn add_item() {
let mut todo = TodoList::new();
todo.add(String::from("Something to do"));
assert_eq!(todo.items.get("Something to do"), Some(&true))
}
The type HashMap
provides a method called insert
[docs] which is exactly what I need
impl TodoList {
...
fn add(&mut self, key: String) {
self.items.insert(key, true);
}
}
And once again this code passes the test, so I consider it good enough.
In Rust self
is a keyword [docs] and not just a name as it happens in Python. Rust considers self
of type Self
[docs], which is the type we are implementing in a trait
or impl
block.
The code fn add(&mut self, key: String) {
above is equivalent to fn add(self: &mut Self, key: String) {
. However, self
cannot be renamed to something like foo
, as Rust is expecting a parameter with that specific name.
I found confusing, at first, that in Rust we usually call &mut
a mutable reference. In my head, I always translate it into a reference to mutable data as this helps me to remember what I am doing here.
In short, in Rust we need to declare explicitly when we intend to consider a value mutable using the keyword mut
[docs], and this is valid also when we pass arguments to functions. If we decide to borrow data instead of moving it, we can use references, that in C terms are equivalent to protected pointers. We can also pass a reference to data that we intend to mutate, which is where &mut
comes into play.
However, as I mentioned I think it's important to understand that the reference (a pointer) is not mutating. The data referenced by it is.
Multiple additions and updates¶
If I add the same key multiple times I want the list to contain only one occurrence, so I test this.
#[test]
fn add_item_already_exist() {
let mut todo = TodoList::new();
todo.add(String::from("Something to do"));
todo.add(String::from("Something to do"));
assert_eq!(todo.items.get("Something to do"), Some(&true));
assert_eq!(todo.items.len(), 1);
}
The test passes already, thanks to the properties of the hash map.
I also want the second insertion not to update the value of the existing element, and in this case the test is
#[test]
fn add_item_does_not_change_value() {
let mut todo = TodoList::new();
todo.add(String::from("Something to do"));
if let Some(x) = todo.items.get_mut("Something to do") {
*x = false;
}
todo.add(String::from("Something to do"));
assert_eq!(todo.items.get("Something to do"), Some(&false));
assert_eq!(todo.items.len(), 1);
}
I have to manually change the value inside the map using get_mut
[docs] that returns a mutable reference to the value. This test doesn't pass, as insert
actually updates the existing value.
At the time of writing the method try_insert
of HashMap
is experimental, so I implemented a custom solution
use std::collections::hash_map::Entry;
[...]
fn add(&mut self, key: String) {
if let Entry::Vacant(entry) = self.items.entry(key) {
entry.insert(true);
}
}
Here, I'm basically checking if an entry for key
is vacant (does not exist) and I create it only in that case. This code passes all tests.
I consider if let
[docs] a very powerful piece of syntax. I care only about one of the possible outcomes, so I don't want to waste time defining it in a full-fledged match
.
Marking items¶
The second method I want to add is mark
that allows me to set the value of the boolean corresponding to a given key. This will be used to flag an item as "done" or "to be done". The test is
#[test]
fn mark_item() {
let mut todo = TodoList::new();
todo.add(String::from("Something to do"));
todo.mark(String::from("Something to do"), false);
assert_eq!(todo.items.get("Something to do"), Some(&false))
todo.mark(String::from("Something to do"), true);
assert_eq!(todo.items.get("Something to do"), Some(&true))
}
Here, I can follow the same strategy I used in the test add_item_does_not_change_value
impl TodoList {
...
fn mark(&mut self, key: String, value: bool) {
if let Some(x) = self.items.get_mut(&key) {
*x = value;
}
}
}
What if the key is not in the list, though? The function get_mut
returns an Option, but mark
should signal with a Result
that something didn't work. I can test this with
#[test]
fn mark_item_does_not_exist() {
let mut todo = TodoList::new();
assert_eq!(
todo.mark(String::from("Something to do"), false),
Err(String::from("Something to do"))
);
}
The new version of the function is then
impl TodoList {
...
fn mark(&mut self, key: String, value: bool) -> Result<String, String> {
let x = self.items.get_mut(&key).ok_or(&key)?;
*x = value;
Ok(key)
}
}
The method ok_or
[docs] converts an Option
into a Result
, so I just call ?
to propagate the error.
The operator ?
is one of the best features of Rust, and it's explained in this chapter of the Rust Book. I find it such a simple yet extremely powerful way to deal with error propagation.
Listing items¶
At this point I want to add the method list
that allows me to see the items contained in TodoList
. I'd like to separate the logic from the presentation so the method will return two lists of items, one for each value of the connected boolean.
This means that the output of the method should in my opinion be a tuple of iterators, one on the items with state "to be done" and one on the ones in state "done".
Iterators are a big thing in Rust, and I can understand why as they definitely boost performances saving memory. The Rust book has a chapter on them, and there is clearly plenty of documentation for the relative trait.
I start with tests as usual
#[test]
fn list_items() {
let mut todo = TodoList::new();
todo.add(String::from("Something to do"));
todo.add(String::from("Something else to do"));
todo.add(String::from("Something done"));
todo.mark(String::from("Something done"), false);
let (todo_items, done_items) = todo.list();
let todo_items: Vec<String> = todo_items.map(|x| x.clone()).collect();
let done_items: Vec<String> = done_items.map(|x| x.clone()).collect();
assert!(todo_items.iter().any(|e| e == "Something to do"));
assert!(todo_items.contains(&String::from("Something else to do")));
assert_eq!(todo_items.len(), 2);
assert!(done_items.contains(&String::from("Something done")));
assert_eq!(done_items.len(), 1);
}
There is a lot to say here, and please remember the caveat that I'm not sure what I'm doing is the best thing.
I add some elements to the list and mark one as done, then I call the method list
to get two iterators and test them. However, iterators can be traversed only once, so to test them properly I prefer to convert them into vectors using the method collect
[docs].
To generate the two iterators I will probably use HashMap::iter
[docs], which means they will have an element type &String
, as we are interested in the item key.
As far as I can tell, there are several different strategies I can use here.
I can generate vectors of &String
using the elements directly from the iterators and then use the method Vec::contains
[docs]. However, the latter wants to receive a reference to the searched value, which means that I would end up with
let todo_items: Vec<&String> = todo_items.collect();
assert!(todo_items.contains(&&String::from("Something else to do")));
While this is perfectly reasonable in terms of memory consumption and performances, the double &&
is a bit ugly. So, considering that I'm writing a test, where performances are not the major concern, I'd prefer to simplify the syntax. I can create a vector of String
values and check them
let todo_items: Vec<String> = todo_items.cloned().collect();
assert!(todo_items.contains(&String::from("Something else to do")));
The syntax todo_items.cloned()
is equivalent to todo_items.map(|x| x.clone())
and leverages the implicit dereferencing of x
. Here, copied()
cannot be used as String
doesn't implement the trait Copy
.
A good alternative to contains
is any
, which however works on iterators. A final version of the code is then
let todo_items: Vec<String> = todo_items.cloned().collect();
assert!(todo_items.iter().any(|e| e == "Something to do"));
Which is also more elegant since it uses the comparison between a String
(which is the iterator item type) and an &str
(the right side). At this point my test is
#[test]
fn list_items() {
let mut todo = TodoList::new();
todo.add(String::from("Something to do"));
todo.add(String::from("Something else to do"));
todo.add(String::from("Something done"));
todo.mark(String::from("Something done"), false);
let (todo_items, done_items) = todo.list();
let todo_items: Vec<String> = todo_items.cloned().collect();
let done_items: Vec<String> = done_items.cloned().collect();
assert!(todo_items.iter().any(|e| e == "Something to do"));
assert!(todo_items.iter().any(|e| e == "Something else to do"));
assert_eq!(todo_items.len(), 2);
assert!(done_items.iter().any(|e| e == "Something done"));
assert_eq!(done_items.len(), 1);
}
An implementation of the method list
that passes this test is
fn list(&self) ->
(impl Iterator<Item = &String>, impl Iterator<Item = &String>) {
(
self.items.iter().filter(|x| *x.1 == true).map(|x| x.0),
self.items.iter().filter(|x| *x.1 == false).map(|x| x.0),
)
}
Here, the powerful keyword impl
declares that whatever comes out of that function implements the Iterator
trait with an element type String
. The code uses iter
[docs] to create an iterator on the elements of the hash map (element type (&String, &bool)
, then uses map
[docs] to extract the first element of each tuple. All in all, the function returns a tuple of Map
[docs] which is a type that implements Iterator
.
Exposing commands on the CLI¶
It's time to expose the methods I implemented on the CLI. I realised that commands like add
and mark-done
require a second argument (the key), other commands like list
don't.
So, the first change is to make the key argument optional.
#[derive(Parser)]
struct Cli {
command: String,
key: Option<String>,
}
Purely to have something to play with, I will also add some values to the list in main
. This is temporary, as long as I don't implement a file storage mechanism.
fn main() {
let args = Cli::parse();
let mut todo = TodoList::new();
todo.add("Something to do".to_string());
todo.add("Something else to do".to_string());
todo.add("Something done".to_string());
todo.mark("Something done".to_string(), false).unwrap();
}
Last, the command-method binding part. A match construct is the best option in this case, something like
match args.command.as_str() {
"add" => ...,
"mark-done" => ...,
"list" => ...
}
The match
control flow construct is a blessing that comes directly from functional programming, where pattern matching is an important tool. The Rust book has a chapter dedicated to it and a chapter on the pattern syntax.
However, since each method has a different return type, I need the whole construct to return a uniform Result
that can be used to print a meaningful state message at the end of the execution.
The code I wrote is the following
fn main() {
...
let result = match args.command.as_str() {
"add" => match args.key {
Some(key) => {
todo.add(key);
Ok(())
}
None => Err("Key cannot be empty!".to_string()),
},
"mark-done" => match args.key {
Some(key) => todo
.mark(key, false)
.map_err(|e| format!("Invalid key {}", e))
.and(Ok(())),
None => Err("Key cannot be empty!".to_string()),
},
"list" => {
let (todo_items, done_items) = todo.list();
println!("# TO DO");
println!();
todo_items.for_each(|x| println!(" * {}", x));
println!();
println!("# DONE");
println!();
done_items.for_each(|x| println!(" * {}", x));
Ok(())
}
cmd => Err(format!("Command {} not recognised", cmd)),
};
match result {
Err(e) => println!("ERROR: {}", e),
Ok(_) => println!("SUCCESS"),
}
}
Tidy up¶
At this point I went through the code and fixed some of the warning the compiler was still giving me. These all come from the tests, where I created the todo
variable but never used it, and where I ignored the results returned by calls of todo.mark
. There, I used unwrap
[docs] as I'm happy for that to panic if something goes wrong.
Final words¶
What a journey so far! It's really true that you can't consider a language learned until you start from scratch and try to use it to implement a real application. Well, it's not over yet, I'm still missing an important part which is the file storage.
If you have comments, suggestions, or corrections, please let me know! I am more than happy to learn something new from other coders and to publish updates to the post.
Feedback¶
Feel free to reach me on Twitter if you have questions. The GitHub issues page is the best place to submit corrections.
Related Posts
TDD in Python with pytest - Part 5
Updated on
TDD in Python with pytest - Part 2
Updated on
TDD in Python with pytest - Part 1
Updated on