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:
Let's get started!
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
andserde_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.
Top comments (0)