DEV Community

Cover image for Creating a todo CLI with Rust 🔥
Posandu
Posandu

Posted on • Originally published at tronic247.com

Creating a todo CLI with Rust 🔥

Hey! 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

Let's get started!

Getting started

First, we create a new project with Cargo:

cargo new todo
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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

fn main() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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};
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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::*;
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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() }
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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::*;
Enter fullscreen mode Exit fullscreen mode

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

pub fn start() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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

utils::init();
Enter fullscreen mode Exit fullscreen mode

We then get the command and arguments:

let args = utils::get_args();
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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

fn main() {
    app::start();
}
Enter fullscreen mode Exit fullscreen mode

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

image

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.

Top comments (0)