DEV Community

Mike Teddy Omondi
Mike Teddy Omondi

Posted on

Learning Rust by Building a CLI App

Prerequisites

Make sure these programs are installed so that you can continue with the tutorial:

  1. Rust compiler - version 1.67.0 (or other newer versions).

  2. Any DBMS of your choice (in my case Beekeeper Studio Community Edition) - for viewing the database records (of which is **OPTIONAL* because we are still going to access our data using our app*)

Why use Rust?

I chose the language just because it boasts about the following:

  1. It is a strongly statically typed programming language.

  2. It is a memory safe programming language.

  3. It has no garbage collection, hence code that passes through the Rust compiler without errors is guaranteed to run safely on any system.

🚩 This tutorial is for intermediate programmers who have had enough introduction of the Rust programming language. In addition, this content is not endorsed or affiliated with the Rust Foundation.

Rust Crates Used

The libraries that are going to be used include:

  1. rusqlite - for allowing SQLite database connections.

  2. prettytable-rs - for showing tables in the console.

  3. uuid - for generating random UUIDs for identifying database records uniquely

📌 Note: there is also another crate async-std in the Cargo.toml (the project's dependency file) but it is not going to be used in this tutorial.

Intro:

As mentioned in the title of this blog, we are going to build a command-line interface (CLI) application that will allow us to create, read, update and delete to-dos - a basic scenario for learning new programming languages. Data will be persisted using SQLite database.

📌 Note: that this application is not to be confused with an API built on Rust's web frameworks!

Here is a sneek peek of the program we would have after this tutorial:

Application Help Menu Screenshot

Creating a new Rust application...

To create a new application we'll use cargo (a build tool and also a package manager for Rust. It is used for scaffolding new library/binary projects). So in your projects folder, you can run this command in your terminal:

cargo new <PROGRAM_NAME_HERE>
Enter fullscreen mode Exit fullscreen mode

📌 I highly recommend you use Rust's official documentation to refer anything that not clear

Command-Line Arguments...

First, to get user commands we will use std::env import to get all the entered commands from console. The application will take the command-line arguments and store them in a vector (a resizable array of elements of type T, allocated on the heap). Our vector array will be of type String. This is done using the following line:

let args: Vec<String> = env::args().collect();
Enter fullscreen mode Exit fullscreen mode

Actions...

Secondly, we have to think of how users will interact with the CLI application through actions they want to take in relation to business logic to be implemented. These actions have been stored in an enum (a data structure that allow us to choose one value from different types of values) and they include the ADD action to add todos to the database, SHOW action that will show all todos in the database, COMPLETE action that will allow flag a todo as completed, DELETE action that will allow remove todos in the database and HELP action that will allow printing out to the console the help information for navigating through the application.

    // Actions Enum
    #[derive(Debug)]
    enum Actions {
        ADD,
        SHOW,
        HELP,
        DELETE,
        COMPLETE,
    }
Enter fullscreen mode Exit fullscreen mode

These actions entered through the console will help the application determine what the user actually wants to do and we are going to do pattern matching with the help of the match expression (which is a way to check whether a value follows a certain pattern and executes different codes for different patterns). Match expression has been rigorously use in this code because it is efficient, handles errors gracefully and makes coding in Rust easier. To match the given action argument provided in the console by the user we use the code below to store the Action type in the action variable. Also if a user doesn't provide any action argument we'll print the help information.

    // If no action argument is provided, we print the help info
    if args.len() != 2 {
        print_help();
        return;
    }

    // take the second argument which is the action
    // cause the program is the first argument
    // Looks like this: program.exe action
    let action_arg = args[1].trim().to_lowercase().to_owned();

    // strings allowed to be entered on the console
    let add_value: String = "add".to_owned();
    let show_value: String = "show".to_owned();
    let help_value: String = "help".to_owned();
    let delete_value: String = "delete".to_owned();
    let complete_value: String = "complete".to_owned();

    let action: Actions = match action_arg {
        add if add == add_value => Actions::ADD,
        show if show == show_value => Actions::SHOW,
        help if help == help_value => Actions::HELP,
        delete if delete == delete_value => Actions::DELETE,
        complete if complete == complete_value => Actions::COMPLETE,
        _ => Actions::HELP,
    };
Enter fullscreen mode Exit fullscreen mode

Database Connection...

Thirdly, we should have dealt with the database connection first but anyway we are here now. I decided to use SQLite database because of its portability and its vast adoption among programmers as per the Stack Overflow Survey 2023 for databases; it will allow the application function on any kind of operating system.

We are going to add new cargo package referred to as rusqlite with bundled features on. So on the console:

cargo add rusqlite --features bundled
Enter fullscreen mode Exit fullscreen mode

Import the package in the beginning of the project file as below:

use rusqlite::{Connection, Error, Result};
Enter fullscreen mode Exit fullscreen mode

Before moving on with database connection we should also think about the structure of a single todo record that wil lbe stored in the database. The structure I used was a struct (a data structure that allows us to get multiple values under the same variable) to hold the different types of information I want it to hold. We will also use the #[derive(Debug)] attribute that tells the compiler to automatically generate the code to be able to print a debug representation of the structure - as used on the enum structure above - and also the #[derive(Clone)] attribute allows cloning a varible to other functions instead of moving them. This is a concept specific to Rust proamming language under ownership & borrowing. The Todo struct will look like this:

// Todo Struct
#[derive(Debug, Clone)]
struct Todo {
    id: String,
    title: String,
    completed: bool,
}
Enter fullscreen mode Exit fullscreen mode

To initialise a database connection, we will use a fucntion that will run in the main function anytime the program is run and provide the database connection for other functions to use. The function will also be responsible for creating the todos table. This function will return a Result enum which may have the returned database connection value of Connection type or the error value of which in this case will be a rusqlite::error type as imported earlier.

// Initialise the database and the table(s)
fn init_db() -> Result<Connection, Error> {
    let conn = Connection::open("todos.db")?;

    conn.execute(
        "create table if not exists todos (
             id text primary key,
             title text not null,
             completed boolean not null 
        )",
        (),
    )?;

    Ok(conn)
}
Enter fullscreen mode Exit fullscreen mode

Accepting User Input...

Fourthly, we should also think of how we are going to accept user input after the progran has run - i.e. after user has provided the action arguments and the program is running. That will require us to accept input for the actions that require creating, updating and deleting i.e. the ADD, COMPLETE and DELETE actions and input them in a buffer for processing with the application.

The function that will will be use is as follows, it will take no parameters and return a value of String type snd this is the mutable line variable at the end of the function as it does not end with a semicolon denoting it will returned after the function runs.

// accepting user input from the console
fn user_input() -> String {
    let mut input_buffer = String::new();
    println!("Enter: ");
    let bytes_read = std::io::stdin().read_line(&mut input_buffer).unwrap();
    println!("No. of bytes read: {}", bytes_read);
    input_buffer
}
Enter fullscreen mode Exit fullscreen mode

Pretty Tables...

Lastly, after accepting user input it reached the time for displaying what we have in our database. I did not want to use the same old println macro for this. Then came the research part for a cargo package that could satisfy this requirement. I wanted the library to be able to display the database records i a tabular format, and thus found the prettytabl-rs cargo package. It is an easy to use package and so I chose it.

To add it as a project dependency, run:

cargo add prettytable-rs
Enter fullscreen mode Exit fullscreen mode

Import it to the project together with the macros and use it by initialising the Table constructor, add the rows using the add_row() function and printing out the table using the printstd() function

// imports
#[macro_use]
extern crate prettytable;

use prettytable::Table;

// ...rest of the code

// code snippet: Prettytable
// Create table
let mut todos_table = Table::new();
// Add a row per time
todos_table.add_row(row![b->"ID", b->"Title", b->"Completed"]);
todos_table.add_row(row![todo.id, todo.title, todo.completed]);
// Print the table to stdout
todos_table.printstd(); 

// ...rest of the code
Enter fullscreen mode Exit fullscreen mode

Conclusion

As we concludethis tutorial, I want to leave at least one implementation of the Actions enum i.e. the ADD action to show how we take the user input, save it to the database using another function and display it to the console using prettytable-rs package.

// Function to add user input(the todo title) to the database
fn add_todo(conn: Connection, todo: Todo) -> Result<usize, Error> {
    // println!("Todo: {:?}", todo);
    let result = conn.execute(
        "INSERT INTO todos (id, title, completed) values (?1, ?2, ?3)",
        [todo.id, todo.title, (todo.completed as i32).to_string()],
    )?;
    Ok(result)
}

// inside the main function
// after getting the database connection
// we use pattern matching against all Actions available
match action {
    Actions::ADD => {
        println!("Add Action: ");
        let input = user_input();

        // Initialise a Todo struct
        let todo = Todo {
            id: Uuid::new_v4().to_string(),
            title: input.trim().to_owned(),
            completed: false,
        };

        // Add the todo to the database and get back the result enum    
        let db_result = add_todo(conn, todo.clone());

        // Pattern matching db_resullt: Result enum Type
        match db_result {
            Ok(usize) => {
                println!("Rows inserted: {:?}", usize);
                println!("Todo added!");

                // Create table
                let mut todos_table = Table::new();

                // Add a row per time
                todos_table.add_row(row![b->"ID", b->"Title", b->"Completed"]);
                todos_table.add_row(row![todo.id, todo.title, todo.completed]);

                // Print the table to stdout
                todos_table.printstd();
            }
            Err(err) => {
                println!("An error occured while inserting database record: {}", err)

            }
        }
    }
// the rest of the patterns to be matched against continue here...
    Actions::SHOW => {}
    Actions::COMPLETE => {}
    Actions::DELETE => {}
    Actions::HELP => {}
}
Enter fullscreen mode Exit fullscreen mode

On the console while running the program with the add action argument, here is how the program outputs:

Application Add Command Screenshot

Also note we had to borrow the Connection struct in the COMPLETE action as you'll see in the full source code here so that the connection variable (named as conn) won't be moved and it will only be copied by the complete_todo() function so that the other function within the same scope i.e. the show_one() function will be able to use the same connection variable

Honestly, this was a great moment for me to get my feet wet on Rust fundamentals and learn as much as I could becase I know and I have seen the potential in this new modern approach it brings to systems programming.

Be sure to get the full source code on GitHub and you can fork it and provide more enhancements to it and drop a pull request for new features and bug-related issues in the future.

🚀 Check out my blog at https://blog.miketeddyomondi.dev for more content on emerging technologies and informational/educational content

Top comments (0)