Published on September 13, 2022 Updated on June 13, 2024

Creating a todo CLI with Rust

In this article, we’ll build a to-do CLI application with Rust. Local JSON files are used to store the data. Here’s a preview of the app:

todo app preview

Contents

Getting started

First, we create a new project with Cargo:

cargo new todo

Then, we add the following dependencies to the Cargo.toml file:

chrono = "0.4.22"
colorize = "0.1.0"
rand = "0.8.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.85"

Here’s what each dependency does:

  • chrono is used to get the current date and time.
  • colorize is used to color the output.
  • rand is used to generate random IDs.
  • serde and serde_json are used to get the data from the JSON file.

Creating the folder structure

Our src folder will look like this:

src
 app
 mod.rs # The app module
 structs
 mod.rs # The structs
 todo
 mod.rs # Todo related functions
 utils
 mod.rs # Utility functions
 main.rs # The main file

Now, in the main.rs file, I’ll import all modules:

mod utils;
mod structs;
mod todo;
mod app;

fn main() {
    // ...
}

Creating the structs

Before we start, let’s create the structs that we’ll use to store the data. In the structs/mod.rs file, we’ll create the following structs:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Todo {
    pub created_at: String,
    pub title: String,
    pub done: bool,
    pub id: u32,
    pub updated_at: String,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct ConfigFile {
    pub data: Vec<Todo>,
}

Creating the utility functions

In the utils/mod.rs file, we’ll import the dependencies and create the utility functions:

use crate::structs;
use chrono;
use colorize::*;
use rand::prelude::*;
use serde_json::from_str;
use serde_json::Result;
use std::{fs, io::Write};

The first function is to create the global data file if it doesn’t exist:

pub fn init() {
    // Check if folder exists
    if !fs::metadata("C:\\.todobook").is_ok() {
        fs::create_dir("C:\\.todobook").unwrap(); // Create folder

        // Create file
        let mut file = fs::File::create(DATA_FILE).unwrap();

        // Write to file
        file.write_all(b"{\"data\":[]}").unwrap();

        println!("{} {}", "Created folder and file".green(), DATA_FILE);
    }

    // Check if file exists
    else if !fs::metadata(DATA_FILE).is_ok() {
        // Create file
        let mut file = fs::File::create(DATA_FILE).unwrap();

        // Write to file
        file.write_all(b"{\"data\":[]}").unwrap();

        println!("{} {}", "Created file".green(), DATA_FILE);
    }
}

The next function is to read the arguments from the command line. Before creating a function, we define a struct called Command in the structs module:

pub struct Command {
    pub command: String,
    pub arguments: String,
}

Now, we can create the get_args function.

pub fn get_args() -> structs::Command {
    let args = std::env::args().collect::<Vec<String>>(); // Get arguments and collect them into a vector

    let command = args.get(1).unwrap_or(&"".to_string()).to_string(); // Get command or set it to an empty string
    let arguments = args.get(2).unwrap_or(&"".to_string()).to_string(); // "" arguments or ""

    structs::Command { command, arguments } // Return the command and arguments
}

The next function returns a timestamp.

pub fn get_timestamp() -> String {
    let now = chrono::Local::now();
    let timestamp = now.format("%m-%d %H:%M").to_string();

    timestamp
}

After that, we create a function to generate a random ID:

pub fn get_id() -> u32 {
    // Genrate number between 1 and 1000
    let mut rng = rand::thread_rng();
    let id: u32 = rng.gen_range(1..1000);

    id + rng.gen_range(1..1000)
}

The next function is to read the data from the JSON file:

pub fn get_todos() -> Result<Vec<structs::Todo>> {
    let data = fs::read_to_string(DATA_FILE).unwrap();
    let todos: structs::ConfigFile = from_str(&data)?;

    Ok(todos.data)
}

The last function is to write the data to the JSON file:

pub fn save_todos(todos: Vec<structs::Todo>) {
    let config_file = structs::ConfigFile { data: todos };
    let json = serde_json::to_string(&config_file).unwrap();

    let mut file = fs::File::create(DATA_FILE).unwrap();
    file.write_all(json.as_bytes()).unwrap();
}

And, that’s it for the utility functions.

Creating the todo functions

Now, we create the functions that will be used to add, remove, and list todos. In the todo/mod.rs file, import the dependencies.

use crate::structs::Todo;
use crate::utils;
use colorize::*;

The first function is to add a todo:

pub fn add(title: String) {
    if title.len() < 1 { // Check if title is empty
        println!("{}", "No title provided".red());

        return;
    }

    let mut todos = utils::get_todos().unwrap(); // Get todos

    let todo = Todo {
        created_at: utils::get_timestamp(),
        title,
        done: false,
        id: utils::get_id(),
        updated_at: utils::get_timestamp(),
    };

    todos.push(todo); // Push todo to todos

    utils::save_todos(todos); // Save todos

    println!("{}", "Added todo".green());
}

The next function is to list todos:

pub fn list() {
    let todos = utils::get_todos().unwrap();

    if todos.len() == 0 {
        println!("{}", "No todos".red());
        return;
    }

    println!(
        "{0: <5} | {1: <20} | {2: <20} | {3: <20} | {4: <20}",
        "ID", "Title", "Created at", "Updated at", "Done"
    );

    println!();

    for todo in todos {
        println!(
            "{0: <5} | {1: <20} | {2: <20} | {3: <20} | {4: <20}",
            todo.id,
            todo.title,
            todo.created_at,
            todo.updated_at,
            if todo.done { "Completed ?".green() } else { "No ?".red() }
        );
    }
}

We then create a function to mark a todo as done:

pub fn done(id: String) {
    let mut todos = utils::get_todos().unwrap();
    let id = id.parse::<u32>().unwrap_or(0);

    let exists = todos.iter().any(|todo| todo.id == id);

    if !exists {
        println!("{}", "Todo not found".red());
        return;
    }

    for todo in &mut todos {
        if todo.id == id {
            todo.done = true;
            todo.updated_at = utils::get_timestamp();
        }
    }

    utils::save_todos(todos);

    println!("{}", "Marked todo as done".green());
}

The next function is to remove a todo:

pub fn remove(id: String) {
    let mut todos = utils::get_todos().unwrap();
    let id = id.parse::<u32>().unwrap_or(0);

    let exists = todos.iter().any(|todo| todo.id == id);

    if !exists {
        println!("{}", "Todo not found".red());
        return;
    }

    todos.retain(|todo| todo.id != id);

    utils::save_todos(todos);

    println!("{}", "Removed todo".green());
}

Now, we have all the functions we need to create a todo app. We should integrate and make it work.

Integrating the functions

In the app/mod.rs file, import the dependencies.

use crate::todo::*;
use crate::utils;
use colorize::*;

We export a start function that will be called in the main.rs file.

pub fn start() {
    // ...
}

We first check and create the data file if it doesn’t exist:

utils::init();

We then get the command and arguments:

let args = utils::get_args();

We then match the command and call the appropriate function:

match args.command.as_str() {
    "a" => add(args.arguments),
    "l" => list(),
    "d" => done(args.arguments),
    "r" => remove(args.arguments),
    "q" => std::process::exit(0),
    _ => {
        /// SHOW HELP
    }
}

As for the help, we do this.

println!("{}", "            No command found - Showing help".black());

let help = format!(
    "
    {} {}
    {}
    -----

    Help:

    Command   | Arguments | Description
    {}           text        Add a new todo
    {}                       List all todos
    {}           id          Mark a todo as done
    {}           id          Delete a todo
    ",
    "Welcome to".grey(),
    "TodoBook".cyan(),
    "Simple todo app written in Rust".black(),
    "a".cyan(),
    "l".blue(),
    "d".green(),
    "r".red()
);

println!("{help}");

Now, in the main.rs file, add this function call:

fn main() {
    app::start();
}

Now, we can run the app using cargo run. You should see something like this:

Conclusion

Thanks for reading. I hope you enjoyed this tutorial. If you have any questions, feel free to ask in the comments. You can also check out the source code here.

Something wrong or just found a typo? Edit this page on GitHub and make a PR!

Comments