DEV Community

Mas Rayfa Nanda Nurimansyah
Mas Rayfa Nanda Nurimansyah

Posted on • Updated on

Build BasicπŸ¦€Rust CLI Todo App Using Clap and SeaOrm

Introduction

It has been enjoyable using Rust to build a CLI app, working with both Clap as the CLI crate and SeaOrm as the ORM. In this hands-on tutorial, we'll walk through the process of creating a fully functional Todo application using Rust. This guide stems from my concern in lack of Rust tutorials that using Clap and SeaOrm altogether. If you're just as newbie as me and looking for light guide to become a Crab Master πŸ¦€, you're in the right place!

Here's the preview

Prerequisites

  • Postgres (my PostgreSql version is 16.0)
  • Rust 1.73.0
  • Cargo

Step 1: Setting up the Project

Cargo new pagawean
cd pagawean
Enter fullscreen mode Exit fullscreen mode

I name the project as pagawean means tasks in sundanese. It's a funny thing to me to name it, so name the project as you like because we will call it in our own cli as the command.

Add required crates just like below.
This is inside the Cargo.toml file until this step:

[package]
name = "pagawean"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = "0.4.31"
clap = { version = "4.4.6", features = ["derive"] }
serde = "1.0.188"
serde_json = "1.0.107"
uuid = { version = "1.4.1", features = ["v4", "serde"]}
sea-orm = { version = "0.12", features = [ "sqlx-mysql", "runtime-tokio-rustls", "macros" ] }
dotenvy_macro = "0.15.7"
tokio = { version = "1.33.0", features = ["full"] }
Enter fullscreen mode Exit fullscreen mode

Step 2: Installing sea-orm and Migrating

Run this command:

cargo install sea-orm-cli
Enter fullscreen mode Exit fullscreen mode

After installing sea-orm-cli we could use that command to do migration. The step we would take is to migrate and then using that migration table scheme to generate an entity later on.

sea-orm-cli migrate init
sea-orm-cli migrate generate "create_todo"
sea-orm-cli migrate generate "create_user"
Enter fullscreen mode Exit fullscreen mode

Once it's completed, SeaOrm will generate a module called migrationin the root project. Inside the srcdirectory, there would be some files that at the end of its name is mblablabla_blabla_create_todo.rsand mblablabla_blabla_create_user.rs as a result from the command we'd done previously. FYI, the convention is mYYYYMMDD_HHMMSS_migration_name.rs from the generated file. If you see a file m20220101_000001_create_table.rs it comes from the sea-orm-cli migrate init. Don't worry just ignore it. You will get the template migration code inside it but we need to change it the way we wanted. We'll get into it later.

This is the project structure should look like:

.
β”œβ”€β”€ migration
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   β”œβ”€β”€ README.md
β”‚   └── src
β”‚       β”œβ”€β”€ lib.rs
β”‚       β”œβ”€β”€ m20220101_000001_create_table.rs
β”‚       β”œβ”€β”€ mblablabla_blabla_create_user.rs
β”‚       β”œβ”€β”€ mblablabla_blabla_create_todo.rs
β”‚       └── main.rs
└── src
    └── main.rs
β”œβ”€β”€ Cargo.lock
β”œβ”€β”€ Cargo.toml
Enter fullscreen mode Exit fullscreen mode

Inside the migration/Cargo.toml, uncomment inside the features [dependencies.sea-orm-migration] like so.

File: migration/Cargo.toml

[dependencies.sea-orm-migration]
version = "0.12.0"
features = [
  # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
  # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
  # e.g.
  "runtime-tokio-rustls",  # `ASYNC_RUNTIME` feature
  "sqlx-postgres",         # `DATABASE_DRIVER` feature
]
Enter fullscreen mode Exit fullscreen mode

In this case, the database we will create just a basic entity which is user and todo. User can have many todo, but each todo can only have one that belongs to user. For a much more clarity, look at this diagram:

Database Diagram

Open the file, then edit inside mblablabla_blabla_create_user.rs based on the diagram. Don't forget to remove todo!() and save it.

File: migration/src/mblablabla_blabla_create_user.rs

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {

        manager
            .create_table(
                Table::create()
                    .table(User::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(User::Id)
                            .string()
                            .not_null()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(User::Name).string().not_null())
                    .col(ColumnDef::new(User::Email).string().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {

        manager
            .drop_table(Table::drop().table(User::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
pub enum User {
    Table,
    Id,
    Name,
    Email,
}
Enter fullscreen mode Exit fullscreen mode

Change inside the mblablabla_blabla_create_todo.rs also like this:

File: migration/src/mblablabla_blabla_create_todo.rs

use sea_orm_migration::prelude::*;
use super::m20231102_030846_create_user::User;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {

        manager
            .create_table(
                Table::create()
                    .table(Todo::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Todo::Id)
                            .string()
                            .not_null()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Todo::Title).string().not_null())
                    .col(ColumnDef::new(Todo::Task).string().not_null())
                    .col(ColumnDef::new(Todo::CreatedAt).date_time().not_null())
                    .col(ColumnDef::new(Todo::DueDate).date_time().not_null())
                    .col(ColumnDef::new(Todo::UserId).string().not_null())
                    .foreign_key(ForeignKey::create()
                        .name("fk_todo_user_id")
                        .from_col(Todo::UserId)
                        .to_tbl(User::Table)
                        .to_col(User::Id)
                        .on_delete(ForeignKeyAction::Cascade)
                        .on_update(ForeignKeyAction::Cascade))
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {

        manager
            .drop_table(Table::drop().table(Todo::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum Todo {
    Table,
    Id,
    Title,
    Task,
    CreatedAt,
    DueDate,
    UserId
}
Enter fullscreen mode Exit fullscreen mode

Create a .env to set URL for the database environment variable:

DATABASE_URL=protocol://username:password@host/database
Enter fullscreen mode Exit fullscreen mode

Based on seaorm docs, this will guide you:

protocol can be mysql:, postgres: or sqlite:. host is usually localhost, a domain name or an IP address.

read this page that linked to the docs database connection

Then, run the migration

sea-orm-cli migrate up
Enter fullscreen mode Exit fullscreen mode

SeaOrm will apply the migrations based on the schema we did earlier.

Step 3: Generating Entities

First we need to create a lib module that's called entity

cargo new entity --lib
Enter fullscreen mode Exit fullscreen mode

Then, we generate entities based on the migration we did previously using sea-orm-cli with the output inside the entity lib module before. The SeaOrm will use the database connection from DATABASE_URL environment variable.

sea-orm-cli generate entity -o entity/src
Enter fullscreen mode Exit fullscreen mode

Then, add sea-orm dependency into it
File: entity/Cargo.toml

[dependencies]
sea-orm = { version = "0.12" }
Enter fullscreen mode Exit fullscreen mode

You should see that we have a generated entity in entity module

File: entity/src/todo.rs

//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "todo")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: String,
    pub title: String,
    pub task: String,
    pub created_at: DateTime,
    pub due_date: DateTime,
    pub user_id: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::UserId",
        to = "super::user::Column::Id",
        on_update = "Cascade",
        on_delete = "Cascade"
    )]
    User,
}

impl Related<super::user::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::User.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}
Enter fullscreen mode Exit fullscreen mode

This is for the user entity
File: entity/src/user.rs

//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: String,
    pub name: String,
    pub email: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    #[sea_orm(has_many = "super::todo::Entity")]
    Todo,
}

impl Related<super::todo::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::Todo.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}
Enter fullscreen mode Exit fullscreen mode

Rename the mod.rs to lib.rs inside the entity/src

Now in order all the newly modules we created can work together and not getting error squiggling, we need to add entity and migration to the workspace and dependencies in our root project
File: Cargo.toml

[workspace]
members = [".", "migration", "entity"]

[dependencies]
entity = { path = "entity" }
migration = { path = "migration" }
Enter fullscreen mode Exit fullscreen mode

So now your whole toml file should look like this
File: Cargo.toml

[package]
name = "pagawean"
version = "0.1.0"
edition = "2021"

[workspace]
members = [".", "migration", "entity"]

[dependencies]
entity = { path = "entity" }
migration = { path = "migration" }
chrono = "0.4.31"
clap = { version = "4.4.6", features = ["derive"] }
serde = "1.0.188"
serde_json = "1.0.107"
uuid = { version = "1.4.1", features = ["v4", "serde"]}
sea-orm = { version = "0.12", features = [ "sqlx-mysql", "runtime-tokio-rustls", "macros" ] }
dotenvy_macro = "0.15.7"
tokio = { version = "1.33.0", features = ["full"] }
Enter fullscreen mode Exit fullscreen mode

little tip: Refresh your text editor if your error squiggle still appearing. DO THIS ESPECIALLY IF YOU'RE USING VS CODE (rust analyzer)

So now this is your project structure until this step

.
β”œβ”€β”€ entity
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   └── src
β”‚       β”œβ”€β”€ lib.rs
β”‚       β”œβ”€β”€ prelude.rs
β”‚       β”œβ”€β”€ todo.rs
β”‚       └── user.rs
β”œβ”€β”€ migration
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   β”œβ”€β”€ Cargo.lock
β”‚   β”œβ”€β”€ README.md
β”‚   └── src
β”‚       β”œβ”€β”€ lib.rs
β”‚       β”œβ”€β”€ m20220101_000001_create_table.rs
β”‚       β”œβ”€β”€ mblablabla_blabla_create_user.rs
β”‚       β”œβ”€β”€ mblablabla_blabla_create_todo.rs
β”‚       └── main.rs
└── src
    └── main.rs
β”œβ”€β”€ Cargo.lock
β”œβ”€β”€ Cargo.toml
Enter fullscreen mode Exit fullscreen mode

Step 4: Coding with Clap

For starters, we gotta need to breakdown what we want to build. We can create 2 possible inputs from our user that they want to create which is an account and a todo list. To get a better view, take a look at this diagram for the main concept:

Main concept

We're gonna create an args take take an input from the user. For the detail command args that we're gonna build you can look at this diagram:
Pagawean Args

Now create an args file inside the src root project. And write code like below:
File: src/args.rs

use clap::{Args, Parser, Subcommand};

#[derive(Parser, Debug)]
#[clap(author, version, about)]
pub struct PagaweanArgs {
    #[clap(subcommand)]
    pub entity_type: EntityType,
}

#[derive(Debug, Subcommand)]
pub enum EntityType {
    /// Create, Update, Delete, Read user
    User(UserCommand),

    /// Create, Update, Delete, Read todos
    Todo(TodoCommand),
}

#[derive(Debug, Args)]
pub struct UserCommand {
    #[clap(subcommand)]
    pub command: UserSubCommand,
}

#[derive(Debug, Subcommand)]
pub enum UserSubCommand {
    /// Create a new user
    Create(CreateUser),

    /// Update an existing user
    Update(UpdateUser),

    /// Delete an existing user
    Delete(DeleteEntity),

    /// Show all users
    Show,
}


#[derive(Debug, Args)]
pub struct CreateUser {
    /// name of the user
    pub name: String,

    /// email of the user
    pub email: String,
}
#[derive(Debug, Args)]
pub struct UpdateUser {
    /// id of the user
    pub id: String,

    /// name of the user
    pub name: String,

    /// email of the user
    pub email: String,
}

#[derive(Debug, Args)]
pub struct DeleteEntity {
    /// id of the user
    pub id: String,
}

#[derive(Debug, Args)]
pub struct TodoCommand {
    #[clap(subcommand)]
    pub command: TodoSubCommand,
}

#[derive(Debug, Subcommand)]
pub enum TodoSubCommand {
    /// Create a new todo
    Create(CreateTodo),

    /// Update an existing todo
    Update(UpdateTodo),

    /// Delete an existing todo
    Delete(DeleteTodo),

    /// Show all todos
    Read,

    /// Show all todos by user id
    ReadByUserId(ReadTodoByUserId),
}

#[derive(Debug, Args)]
pub struct CreateTodo {
    /// title of the todo
    pub title: String,

    /// task of the todo
    pub task: String,

    /// due date of the todo
    pub duedate: String,

    /// user id of the todo
    pub user_id: String,
}

#[derive(Debug, Args)]
pub struct UpdateTodo {
    /// id of the todo
    pub id: String,

    /// title of the todo
    pub title: String,

    /// task of the todo
    pub task: String,

    /// due date of the todo
    pub duedate: String,

    /// user id of the todo
    pub user_id: String
}

#[derive(Debug, Args)]
pub struct DeleteTodo {
    /// id of the todo
    pub id: String,
}

#[derive(Debug, Args)]
pub struct ReadTodoByUserId {
    /// user id of the todo
    pub user_id: String,
}
Enter fullscreen mode Exit fullscreen mode

Here's what you gotta understand is that, the derive features is used heavily in this code. So what I could tell you is that #[clap(subcommnad)] tells you the field underneath it represents a subcommand when parsing command-line arguments.

Take a look at this snippet code that we wrote before:
File: src/args.rs

#[derive(Parser, Debug)]
#[clap(author, version, about)]
pub struct PagaweanArgs {
    #[clap(subcommand)]
    pub entity_type: EntityType,
}
Enter fullscreen mode Exit fullscreen mode

By using this attribute, you're telling clap that the value provided for entity_type should correspond to one of the subcommands defined in the EntityType enum.

In this case, clap will recognize that entity_type is a subcommand field and expect the user to provide a subcommand, such as user or todo. Which is:
File: src/args.rs

#[derive(Debug, Subcommand)]
pub enum EntityType {
    /// Create, Update, Delete, Read user
    User(UserCommand),

    /// Create, Update, Delete, Read todos
    Todo(TodoCommand),
}
Enter fullscreen mode Exit fullscreen mode

By defining a struct for this purpose, you are providing a clear and structured way for users to input the details of a new todo item from the command line.

File: src/args.rs

#[derive(Debug, Args)]
pub struct CreateTodo {
    pub title: String,
    pub task: String,
    pub duedate: String,
    pub user_id: String,
}
Enter fullscreen mode Exit fullscreen mode

From the code above, when your program is executed with the appropriate command and arguments, clap will use the structure of this CreateTodo struct to parse and validate the provided command-line inputs. This applies to other structs we wrote earlier.

Step 5: Integrating with SeaOrm

First we're gonna create a db.rs that will establish a connection to our local database, so that when we create a handler, the db module will connect to our local database.

Create a new file called db.rs, then write this code:
File: src/db.rs

use std::time::Duration;

use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbErr, DbConn};

pub async fn establish_connection(database_uri: &str) -> Result<DbConn, DbErr>{
    let mut opt = ConnectOptions::new(database_uri);
    opt.max_connections(100)
        .min_connections(5)
        .idle_timeout(Duration::from_secs(8));

    let database: Result<DatabaseConnection, DbErr> = Database::connect(opt).await;

    match database {
        Ok(db) => {
            println!("Connected to database");
            Ok(db.into())
        },
        Err(err) => {
            println!("Failed to connect to database");
            Err(err)
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Next We're gonna create an ops as a handler that operate every time a certain command is called.

First let's create a new directory called ops, inside that directory create files called todo_ops.rs and user_ops.rs. In order rust knows that there is a new module called ops, we're gonna need to create mod.rs. And inside that mod.rs write this code:
File: src/ops/mod.rs

pub mod todo_ops;
pub mod user_ops;
Enter fullscreen mode Exit fullscreen mode

You might still get a error squiggle, so add this code inside the src/main.rs on the top of the file.
File: src/main.rs

mod ops;
Enter fullscreen mode Exit fullscreen mode

Write user_ops handler

In our user_ops.rs, it will handle the UserCommand and it will have a function that acts as an entry point that will responsible for a match enum based on the UserCommand input whether it's CREATE or UPDATE or DELETE or SHOW.

File: src/ops/user_ops.rs

use dotenvy_macro::dotenv;
use sea_orm::{EntityTrait, Set, ActiveModelTrait, DeleteResult, DbConn, DbErr};
use uuid::Uuid;

use crate::db::{self, establish_connection};
use crate::args::{CreateUser, UpdateUser, DeleteEntity, UserSubCommand, UserCommand};

pub async fn handle_user_command(user: UserCommand) {
    let command = user.command;

    match command {
        UserSubCommand::Create(user) => {
            create_user(user).await;
        },
        UserSubCommand::Update(user) => {
            update_user(user).await;
        }
        UserSubCommand::Delete(user) => {
            delete_user(user).await;
        }
        UserSubCommand::Show => {
            show_users().await;
        }
    }
}

async fn create_user(user: CreateUser) {
    println!("Creating user: {:?}", user);


    let database_uri = dotenv!("DATABASE_URL");
    let db = db::establish_connection(database_uri).await;

    let id = Uuid::new_v4();

    let new_user = entity::user::ActiveModel {
        id: Set(id.to_string()),
        name: Set(user.name),
        email: Set(user.email),
        ..Default::default()
    };

    match db {
        Ok(db) => {
            let user = new_user.insert(&db).await.unwrap();
            println!("User created: {:?}", user);
        },
        Err(err) => {
            eprint!("Failed to establish a database connection and create user: {}", err)
        }
    }

}

async fn update_user(user: UpdateUser) {
    println!("Updating user: {:?}", user);

    let database_uri = dotenv!("DATABASE_URL");
    let db = db::establish_connection(database_uri).await;

    let id = Uuid::parse_str(&user.id).unwrap();

    type UserModel = entity::user::Model;

    match db {
        Ok(db) => {
            let find_user: Option<UserModel> = entity::user::Entity::find_by_id(id.clone())
                .one(&db)
                .await
                .unwrap();
            let user_active_model : entity::user::ActiveModel = find_user.unwrap().into();

            let updated_user = entity::user::ActiveModel {
                id: Set(id.to_string()),
                name: Set(user.name),
                email: Set(user.email),
                ..user_active_model
            };

            let updated_user: entity::user::Model = updated_user.update(&db).await.unwrap();

            println!("User updated: {:?}", updated_user);
        },

        Err(err) => {
            eprint!("Failed to establish a database connection and update user: {}", err)
        }
    }

}

async fn delete_user(user: DeleteEntity) {
    println!("Deleting user: {:?}", user);

    let database_uri = dotenv!("DATABASE_URL");
    let db = establish_connection(database_uri).await;

    let id = Uuid::parse_str(&user.id).unwrap();

    match db {
        Ok(db) => {
            let res: DeleteResult = entity::user::Entity::delete_by_id(id.clone())
                .exec(&db)
                .await
                .unwrap();

            println!("User deleted: {:?}", res);
        }
        Err(err) => {
            eprint!("Failed to establish a database connection and delete user: {}", err)
        }  
    }
}

async fn show_users() {
   println!("Showing users"); 

   let database_uri = dotenv!("DATABASE_URL");
   let db: Result<DbConn, DbErr> = establish_connection(database_uri).await;

    match db {
         Ok(db) => {
              let users: Vec<entity::user::Model> = entity::user::Entity::find()
                .all(&db)
                .await
                .unwrap();

              println!("Users: {:?}", users);
         },
         Err(err) => {
              eprint!("Failed to establish a database connection and show users: {}", err)
         }
    }
}
Enter fullscreen mode Exit fullscreen mode

Most of the code we wrote in user_ops.rs has the same pattern. we establish database connection, then if we try to find/read first to the database then wanna do something to it we're gonna use an Entity to do database stuff then using ActiveModel if we want to have ActiveValue from it.

Write todo_ops handler

In our todo_ops.rs, it will handle the TodoCommand and it will have a function that acts as an entry point that will responsible for a match enum based on the TodoCommand input whether it's CREATE or UPDATE or DELETE or READ, READBYUSERID.

Based on my explanation, You know I just got a bit tired right?HAHAHAA. But I assure you that it's not so much different with user_ops.rs

Maybe there's a few I want to add that you gotta pay attention with Set() whenever we want to update or create an ActiveModel.

Write helper module

This is actually not critical, but in my case I want to have a function that I'm gonna reuse it many times so I create a helper module to parse a date.

File: src/helper/parse_date.rs

use chrono::NaiveDateTime;

// parse the date string into a NaiveDate
pub fn parse_date(date: &str) -> NaiveDateTime {
    let date: NaiveDateTime = NaiveDateTime::parse_from_str(date, "%Y-%m-%d %H:%M:%S").unwrap();
    date
}
Enter fullscreen mode Exit fullscreen mode

Also don't forget to create mod.rs inside helper module and make it public.
File: src/helper/mod.rs

pub mod parse_date;
Enter fullscreen mode Exit fullscreen mode

Step 6: Bringing it all together

Now we finally will bring it all into one place which is src/main.rs. Inside here gonna be a main entry from all the input command line we just created. We're gonna parse the arguments and will be using pattern matching based on the entity type.

File: src/main.rs

mod args;
mod db;
mod ops;
mod helper;

use args::EntityType;
use args::PagaweanArgs;
use clap::Parser;
use ops::{todo_ops, user_ops};

#[tokio::main]
async fn main() {
    let args = PagaweanArgs::parse();

    match args.entity_type {
        EntityType::User(user) => user_ops::handle_user_command(user).await,
        EntityType::Todo(todo) => todo_ops::handle_todo_command(todo).await
    }
}
Enter fullscreen mode Exit fullscreen mode

The magic is that clap will parse then handle it based on the enum SubCommand inside the pattern match-ing. The other magic is we're using [tokio::main] to have a await feature or asynchronous function.

Done. That is it. I know it's not a perfect code but I hope you'll get something from it. This post is not for you only to learn but also for me since I'm forgetful :) Definitely I'm gonna look at this post and try to relearn.

LET'S BECOME A CRAB MASTER!!!πŸ¦€

Prime

Code: https://github.com/masrayfa/rust-todo-clap

Top comments (0)