Build a Rust CLI Using Seahorse

September 04, 2023
Written by
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Twilion

The command line interface (CLI) still plays a key role in software development, despite the popularity of graphical user interfaces (GUIs).

Apart from the fact that the CLI is not as resource intensive as a GUI, it also allows the user to take advantage of access to privileged commands in the system. This makes it especially useful to software engineers, as the added flexibility and control it provides enables them carry out their tasks very effectively. In fact, building a CLI for your product can enhance the developer experience.

Previously, I have written on how to build a CLI with PHP and Go. In this tutorial, I will show you how to build one in Rust using Seahorse.

Memory safety, thread safety, and the absence of a garbage collector make Rust a strong candidate when building performance-critical software. Building complex CLIs require a mix of these features. Seahorse takes away some of the complexity by handling flag typing and parsing for you. This allows you to focus on the problem domain instead of trying to parse user input.

To get familiar with Seahorse, I will walk you through the process of building a CLI that encrypts and decrypts text based on a specified cipher. Additionally, if the user adds the appropriate flag, the CLI will send an encrypted message to someone.

Prerequisites

To follow this tutorial, you will need the following:

What you will build

In this tutorial, you will build a CLI that can generate secret messages using substitution ciphers. While such algorithms are not secure enough to save your passwords, they are certainly enough to share funny jokes with your friends without prying eyes spoiling the fun.

To keep things short, the application will only use two algorithms: Caesar’s cipher and Bacon’s cipher. In addition, your application will be able to directly send the encrypted text as a WhatsApp message to a specified phone number via Twilio.

Get started

Where you create your Rust projects, create a new Rust project and change into it using the following commands.

cargo new cipher_cli
cd cipher_cli

Add project dependencies

Update the dependencies section of the Cargo.toml file to match the following.

dependencies]
colored = "2.0.4"
dotenv = "0.15.0"
regex = "1.9.3"
reqwest = { version = "0.11.18"

Here’s what each added crate does:

  • Colored: This crate will be used to colour the CLI output. Information from the CLI will be yellow, error messages will be red while a successful action is coloured green.
  • Regex: This crate provides routines for searching strings for matches of a regular expression (aka “regex”).
  • Reqwest: This package will simplify sending API requests to Twilio.
  • rust-dotenv: This package helps keep secure information, such as API keys and tokens, out of code, by setting them as environment variables.
  • Seahorse: Seahorse is a minimalistic Rust framework that simplifies the process of building a CLI, by providing helper structs and methods to parse user input (and flags) and make them available in the preferred data type.
  • serde, serde_derive, and serde_json: These packages reduce the complexity of deserialising JSON responses from Twilio, so that they can be more easily used.

Set the required environment variables

Now, create a new file called .env in the project's top-level directory, and paste the following code into it.

TWILIO_ACCOUNT_SID="<<TWILIO_ACCOUNT_SID>>"
TWILIO_AUTH_TOKEN="<<TWILIO_AUTH_TOKEN>>"
TWILIO_WHATSAPP_NUMBER="<<TWILIO_WHATSAPP_NUMBER>>"

After that, retrieve your Twilio Auth Token, Account SID, and phone number from the Twilio Console Dashboard and insert them in place of the respective placeholders.

Build the application

Now that you have your Twilio credentials, you’re set to start building the application. To make your code easy to follow, you’ll build separate modules to handle separate concerns.

Implement encryption and decryption algorithms

Next, let’s add the modules that will handle encryption and decryption for the selected algorithms.

The Bacon algorithm

In the src folder, create a new file named bacon.rs and add the following code to it.

use std::collections::HashMap;

const CHUNK_SIZE: usize = 5;

pub fn encrypt(plaintext: &str) -> String {
    let lookup: HashMap<char, &str> = HashMap::from([
        ('A', "aaaaa"), ('B', "aaaab"), ('C', "aaaba"), ('D', "aaabb"), ('E', "aabaa"),
        ('F', "aabab"), ('G', "aabba"), ('H', "aabbb"), ('I', "abaaa"), ('J', "abaab"),
        ('K', "ababa"), ('L', "ababb"), ('M', "abbaa"), ('N', "abbab"), ('O', "abbba"),
        ('P', "abbbb"), ('Q', "baaaa"), ('R', "baaab"), ('S', "baaba"), ('T', "baabb"),
        ('U', "babaa"), ('V', "babab"), ('W', "babba"), ('X', "babbb"), ('Y', "bbaaa"),
        ('Z', "bbaab"),
    ]);

    let mut ciphertext = String::new();
    for character in plaintext.chars() {
        let key = &character.to_ascii_uppercase();
        if lookup.contains_key(key) {
            let cipher = lookup.get(key).unwrap();

            if character.is_uppercase() {
                ciphertext = format!("{ciphertext}{}", cipher.to_uppercase())
            } else {
                ciphertext = format!("{ciphertext}{cipher}")
            }
        } else {
            ciphertext = format!("{ciphertext}{character}");
        }
    }
    ciphertext
}

pub fn decrypt(ciphertext: &str) -> String {
    let reverse_lookup: HashMap<&str, char> = HashMap::from([
        ("aaaaa", 'A'), ("aaaab", 'B'), ("aaaba", 'C'), ("aaabb", 'D'), ("aabaa", 'E'),
        ("aabab", 'F'), ("aabba", 'G'), ("aabbb", 'H'), ("abaaa", 'I'), ("abaab", 'J'),
        ("ababa", 'K'), ("ababb", 'L'), ("abbaa", 'M'), ("abbab", 'N'), ("abbba", 'O'),
        ("abbbb", 'P'), ("baaaa", 'Q'), ("baaab", 'R'), ("baaba", 'S'), ("baabb", 'T'),
        ("babaa", 'U'), ("babab", 'V'), ("babba", 'W'), ("babbb", 'X'), ("bbaaa", 'Y'),
        ("bbaab", 'Z'),
    ]);

    let mut plaintext = String::new();
    let mut chunk = String::new();
    let characters = ciphertext.as_bytes();

    for i in 0..ciphertext.len() {
        let character = characters[i] as char;
        if !character.is_alphabetic() {
            plaintext = format!("{plaintext}{character}");
        } else {
            chunk = format!("{chunk}{character}");
        }
        if chunk.len() == CHUNK_SIZE {
            let key = chunk.to_lowercase();
            if reverse_lookup.contains_key(key.as_str()) {
                let plain_character = reverse_lookup.get(key.as_str()).unwrap();
                if chunk.eq(chunk.to_lowercase().as_str()) {
                    plaintext = format!("{plaintext}{}", plain_character.to_lowercase())
                } else {
                    plaintext = format!("{plaintext}{plain_character}")
                }
            } else {
                plaintext = format!("{plaintext}{chunk}");
            }
            chunk = String::new();
        }
    }
    plaintext
}

For this article, the 26 character alphabet cipher is being used. This ensures that each alphabet has a unique cipher. The Bacon cipher replaces a character with a sequence of 5 characters. Since each character has a predefined replacement, a map named lookup is used to store the replacement for each character.

In the Encrypt() function, the string to be encrypted is provided as an argument. This function iterates over each character in the provided string and retrieves the associated replacement from the lookup map. The replacements for each character are concatenated into a string and returned as the encryption version of the input string.

The Decrypt() function works in reverse. A reverseLookup map is declared which has the decrypted character for each five character sequence. Given an encrypted sequence, the function iterates through the sequence and generates five character chunks. For each chunk, the function checks for the decrypted value of the chunk in the reverseLookup map. The decrypted values are concatenated and returned similar to the process in the Encrypt() function.

The Caesar algorithm

In the src folder, create a new file named caesar.rs and add the following code to it.

const ALPHABETS: &str = "abcdefghijklmnopqrstuvwxyz";

pub fn encrypt(plaintext: &str, rotations: i32) -> String {
    let mut ciphertext = String::new();
    for character in plaintext.chars() {
        let character_index = character_index(character.to_ascii_lowercase());
        if character_index == -1 {
            ciphertext = format!("{ciphertext}{character}")
        } else {
            let cipher_index = cipher_index(&character_index, &rotations);
            let cipher = replacement_character(cipher_index);
            if character.is_uppercase() {
                ciphertext = format!("{ciphertext}{}", cipher.to_uppercase())
            } else {
                ciphertext = format!("{ciphertext}{cipher}")
            }
        }
    }
    ciphertext
}

fn character_index(element: char) -> i32 {
    let index = ALPHABETS.find(element);
    match index {
        None => { -1 }
        Some(i) => { i.try_into().unwrap() }
    }
}

fn cipher_index(plain_index: &i32, key: &i32) -> usize {
    let new_index = plain_index + key;
    if new_index > 25 {
        return (new_index % 26) as usize;
    }
    new_index as usize
}

fn replacement_character(index: usize) -> char {
    ALPHABETS.as_bytes()[index] as char
}

pub fn decrypt(ciphertext: &str, rotations: i32) -> String {
    let mut plaintext = String::new();
    for character in ciphertext.chars() {
        let character_index = character_index(character.to_ascii_lowercase());
        if character_index == -1 {
            plaintext = format!("{plaintext}{character}")
        } else {
            let plain_index = plain_index(&character_index, &rotations);
            let plain_character = replacement_character(plain_index);
            if character.is_uppercase() {
                plaintext = format!("{plaintext}{}", plain_character.to_uppercase())
            } else {
                plaintext = format!("{plaintext}{plain_character}")
            }
        }
    }
    plaintext
}

fn plain_index(cipher_index: &i32, key: &i32) -> usize {
    let mut new_index = cipher_index - key;
    if new_index < 0 {
        new_index = 26 + (&new_index % 26)
    }
    if new_index > 25 {
        return (new_index % 26) as usize;
    }
    return new_index as usize;
}

To encrypt or decrypt a Caesarean cipher, you need the input text and the number of rotations for each encryption. For each character in the text, the position in the alphabet is calculated. Then using the number of rotations, the position of the replacement text is determined and the character in that position retrieved. This is done for all characters in the input sequence. All the replacement characters are concatenated and returned as the output string.

Bear in mind that the number of rotations provided can exceed the number of letters in the alphabet (26) and because of that, you need a function to determine the index of the character to be used for replacement. For encryption, the cipher_index() function is used, while the plain_index() function is used for decryption.

Add functionality for WhatsApp notifications

At the moment, there’s no Twilio SDK available for Rust. However, using Twilio’s Programmable Messaging API, it is possible to send a JSON request for the purpose of sending WhatsApp messages. The next module you will add will contain the relevant structs and functions to simplify the process of dispatching notifications from your CLI.

In the src folder, create a new file named notification.rs and add the following code to it.

use std::env;

use reqwest::{blocking::Client, StatusCode};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct SuccessResponse {
    api_version: String,
    body: String,
    date_created: String,
    date_sent: Option<String>,
    date_updated: String,
    direction: String,
    from: String,
    messaging_service_sid: Option<String>,
    price: Option<String>,
    price_unit: Option<String>,
    sid: String,
    status: String,
    to: String,
    uri: String,
}

#[derive(Serialize, Deserialize)]
struct ErrorResponse {
    code: u16,
    message: String,
    more_info: String,
    status: u16,
}

fn error_message(body: String) -> String {
    let error_response: ErrorResponse = serde_json::from_str(&body).expect("Unable to deserialise JSON error response.");
    format!("There was a problem sending your message :( \nWhatsApp message was not sent because: {:?}.", error_response.message)
}

fn success_message(body: String) -> String {
    let response: SuccessResponse = serde_json::from_str(&body).expect("Unable to deserialise JSON success response.");
    format!("Notification handled successfully!! \nYour WhatsApp message with content {:?} is now {:?}.", response.body, response.status)
}

pub fn send_whats_app_message(message: String, recipient_phone_number: &str) -> (String, bool) {
    let twilio_account_sid =
        env::var("TWILIO_ACCOUNT_SID").expect("Twilio Account SID could not be retrieved.");
    let twilio_auth_token =
        env::var("TWILIO_AUTH_TOKEN").expect("Twilio Auth Token could not be retrieved.");
    let twilio_phone_number =
        env::var("TWILIO_WHATSAPP_NUMBER").expect("The Twilio phone number could not be retrieved.");

    let request_url =
        format!("https://api.twilio.com/2010-04-01/Accounts/{twilio_account_sid}/Messages.json");

    let formatted_phone_number = format!("whatsapp:{recipient_phone_number}");
    let formatted_twilio_phone_number = format!("whatsapp:{twilio_phone_number}");
    let client = Client::new();
    let request_params = [
        ("To", formatted_phone_number),
        ("From", formatted_twilio_phone_number),
        ("Body", message.to_string()),
    ];

    let response = client
        .post(request_url)
        .basic_auth(twilio_account_sid, Some(twilio_auth_token))
        .form(&request_params)
        .send();

    match response {
        Ok(response) => {
            let status = response.status();
            let body = match response.text() {
                Ok(result) => result,
                Err(error) => panic!(
                    "Problem extracting the JSON body content. Reason: {:?}",
                    error
                ),
            };
            match status {
                StatusCode::BAD_REQUEST => (error_message(body), false),
                StatusCode::OK | StatusCode::CREATED => (success_message(body), true),
                _ => (format!("Received status code: {status}"), false),
            }
        }
        Err(error) => { panic!("Problem executing request. Reason: {:?}", error) }
    }
}

Two structs are modelled after the possible responses from the Twilio API. The SuccessResponse takes a subset of the information returned by the API for a successful request, while the ErrorResponse struct is modelled after the response for an unsuccessful request.

The send_whats_app_message() is used to send a specified message to the provided phone number. This function returns a tuple containing the outcome of the action (a String, and a boolean indicating success or failure).

To do this, a JSON request is sent containing the message, the recipient, and the requisite authentication for a request, retrieved from the environment variables. Using the error_message() and success_message(), the API response is parsed and a string message is generated. Depending on the HTTP response status code, a boolean is returned alongside the generated string.

Add validation functionality

To ensure that the application functions smoothly, you also need to filter some of the user input. There are so many other things your code could (and should) filter to ensure your application works smoothly. But, to keep things simple, this application will only filter the input for the number of rotations and the input for the recipient’s phone number.

In the src folder, create a new file named input_filter.rs and add the following code to it.

use regex::Regex;

pub fn number_of_rotations(input: isize) -> Option<i32> {
    if input > 0 {
        return Some(input as i32);
    }
    None
}

pub fn recipient_phone_number(input: &str) -> Option<&str> {
    if let Ok(regex) = Regex::new(r"^\+[1-9]\d{12,14}$") {
        if regex.is_match(input) {
            return Some(input);
        }
    };
    None
}

Both functions return an Option. If the provided value is valid, it is returned in the Some variant. Otherwise, the None variant is returned.

The number_of_rotations() function takes an input integer and checks to ensure that it is greater than 0. This is to prevent unexpected behaviour during the encryption and decryption processes as a result of negative input.

The recipient_phone_number() function takes a string slice and matches the value against the regular expression for a valid E.164 phone number. In the event that the provided phone number is invalid, the None variant is returned.

Regex validation is used in this article for the sake of simplicity. You could use the Twilio Lookup API for a more robust validation.

Next, add a helper struct which will hold the CLI response message. In addition to the message, the struct will also have a boolean flag which indicates whether or not the response to be printed is for an error or not.

In the src folder, create a new file named cli_response.rs and add the following code to it.

use colored::Colorize;

pub struct CLIResponse {
    message: String,
    is_error: bool,
}

impl CLIResponse {
    pub fn new() -> Self {
        Self {
            message: String::new(),
            is_error: false,
        }
    }

    pub fn success_update(&mut self, message: String) {
        self.message = message;
        self.is_error = false;
        self.print_result();
    }

    pub fn error_update(&mut self, message: String) {
        self.message = message;
        self.is_error = true;
        self.print_result();
    }

    fn print_result(&self) {
        if self.is_error {
            println!("\n{}", "Error!!".bright_white().on_bright_red().bold());
            println!("{}", self.message.red().bold());
        } else {
            println!("\n{}", "Success!!".bright_white().on_bright_green().italic());
            println!("{}", self.message.green().italic());
        }
    }

    pub fn is_success(&self) -> bool {
        !self.is_error
    }
}

The CLIResponse struct has two fields, one for the message and another for whether or not the message is an error message. The struct also has four functions, namely:

  1. new(): This is used to create a new instance of the struct.
  2. success_update(): This is used to add a success message to the struct.
  3. error_update(): This is used to add an error message to the struct.
  4. print_result(): This is used to print the saved response to the terminal. The output is coloured (based on whether or not the message being printed is an error message)
  5. is_success(): This is used to determine whether the struct contains a success message. This function will be used to determine whether or not some additional actions can be carried out.

Putting it all together

With all the other components of the application in place, it’s time to update the application entry point to handle user input and perform accordingly. Update the code in src/main.rs to match the following.

use std::env;

use colored::Colorize;
use dotenv::dotenv;
use seahorse::{App, Command, Context, Flag, FlagType};
use seahorse::error::FlagError;

use cli_response::CLIResponse;

mod caesar;
mod bacon;
mod input_filter;
mod notification;

mod cli_response;

fn main() {
    dotenv().ok();

    let args: Vec<String> = env::args().collect();
    let app = App::new("cipher_cli")
        .description("Encrypt and decrypt secret messages in seconds!!!")
        .usage("cipher_cli [command] [arg]")
        .action(base_action())
        .command(decrypt_command())
        .command(encrypt_command());

    app.run(args);
}

fn base_action() -> fn(&Context) {
    |_c: &Context| {
        println!("{}", r#"
        This application encrypts and decrypts secret messages with ease.
        Try out the Caesar and Bacon Cipher options to generate secret messages and share with your inner circle
        "#.yellow()
        );
        println!("{}", r#"
        Example Usage

        cipher_cli encrypt Welcome to the hallowed chAmbers!  --algorithm=caesar --rotations=54

        cipher_cli encrypt Welcome to the hallowed chambers! --algorithm=bacon

        cipher_cli decrypt Ygneqog vq vjg jcnnqygf ejCodgtu! --algorithm=caesar --rotations=54

        "#.blue())
    }
}

fn decrypt_command() -> Command {
    Command::new("decrypt")
        .description("decrypt input using a specified algorithm")
        .usage("cipher_cli decrypt(de, d) [input] -r=[rotations] -a=[algorithm]")
        .alias("d")
        .alias("de")
        .action(decrypt_action)
        .flag(
            Flag::new("rotations", FlagType::Int)
                .description("rotations flag")
                .alias("r"),
        )
        .flag(
            Flag::new("algorithm", FlagType::String)
                .description("algorithm flag")
                .alias("a")
                .alias("al"),
        )
}

fn decrypt_action(c: &Context) {
    let input = &c.args.join(" ");
    let mut response = CLIResponse::new();

    if let Ok(algorithm) = c.string_flag("algorithm") {
        match algorithm.to_lowercase().as_str() {
            "bacon" => {
                let plaintext = bacon::decrypt(input);
                response.success_update(format!("Ciphertext: {input} \nPlaintext: {plaintext}"));
            }
            "caesar" => match c.int_flag("rotations") {
                Ok(rotations) => if let Some(rotations) = input_filter::number_of_rotations(rotations) {
                    let plaintext = caesar::decrypt(input, rotations);
                    response.success_update(format!("Ciphertext: {input} \nRotations: {rotations} \nPlaintext: {plaintext}"));
                } else {
                    response.error_update(format!("Rotations cannot be less than 0, {rotations} provided"));
                },
                Err(e) => match e {
                    FlagError::NotFound | FlagError::Undefined => {
                        response.error_update(String::from("Required flag \"rotations\" not provided"));
                    }
                    _ => {
                        response.error_update(String::from("Invalid value provided for \"rotations\""));
                    }
                }
            },
            _ => {
                response.error_update(String::from("Unknown algorithm provided"));
            }
        }
    } else {
        response.error_update(String::from("Required flag \"algorithm\" not provided"));
    }
}

fn encrypt_command() -> Command {
    Command::new("encrypt")
        .description("encrypt command")
        .usage("cipher_cli encrypt [input]")
        .action(encrypt_action)
        .flag(
            Flag::new("rotations", FlagType::Int)
                .description("rotations flag")
                .alias("r")
                .alias("ro"),
        )
        .flag(
            Flag::new("algorithm", FlagType::String)
                .description("algorithm flag")
                .alias("a")
                .alias("al"),
        )
        .flag(
            Flag::new("recipient", FlagType::Int)
                .description("recipient flag")
                .alias("re"),
        )
}

fn encrypt_action(c: &Context) {
    let input = &c.args.join(" ");
    let mut response = CLIResponse::new();

    if let Ok(algorithm) = c.string_flag("algorithm") {
        let mut ciphertext = String::new();
        match algorithm.to_lowercase().as_str() {
            "bacon" => {
                ciphertext = bacon::encrypt(input);
                response.success_update(format!("Plaintext: {input} \nCiphertext: {ciphertext}\n"));
            }
            "caesar" => match c.int_flag("rotations") {
                Ok(rotations) => if let Some(rotations) = input_filter::number_of_rotations(rotations) {
                    ciphertext = caesar::encrypt(input, rotations);
                    response.success_update(format!("Plaintext: {input} \nRotations: {rotations} \nCiphertext: {ciphertext}\n"));
                } else {
                    response.error_update(format!("Rotations cannot be less than 0, {rotations} provided"));
                },
                Err(e) => match e {
                    FlagError::NotFound | FlagError::Undefined => {
                        response.error_update(String::from("Required flag \"rotations\" not provided"));
                    }
                    _ => {
                        response.error_update(String::from("Invalid value provided for \"rotations\""));
                    }
                }
            },
            _ => {
                response.error_update(String::from("Unknown algorithm provided"));
            }
        }

        if response.is_success() {
            if let Ok(recipient) = c.int_flag("recipient") {
                let (notification_response, is_successful) = handle_notification(ciphertext, recipient);
                if is_successful {
                    response.success_update(notification_response);
                } else {
                    response.error_update(notification_response);
                }
            }
        }
    } else {
        response.error_update(String::from("Required flag \"algorithm\" not provided"));
    }
}

fn handle_notification(ciphertext: String, recipient: isize) -> (String, bool) {
    if let Some(recipient) = input_filter::recipient_phone_number(format!("+{recipient}").as_str()) {
        return notification::send_whats_app_message(format!("From your partner in mischief: {ciphertext}"), recipient);
    }
    (String::from("Invalid phone number provided for \"recipient\""), false)
}

The entry point for the application is the main() function. In this function, the environment variables are loaded and the CLI arguments are collected. Then, an App struct is instantiated and the run() function is called with the CLI arguments passed as a parameter.

The base_action() function returns an anonymous function which is executed when no additional commands are called. To trigger this action, you can run the following command.

cargo run

This command prints the following coloured output to the terminal

Example of terminal output after running the application

The App struct is instantiated with two commands as defined in the decrypt_command() and encrypt_command() functions. These functions instantiate a Command struct with the relevant description, alias(es), and flags specified. Both commands also have a corresponding function: decrypt_action() and encrypt_action(), which parse the provided flags and execute accordingly.

The encrypt_action() also checks if a recipient flag is provided, indicating that the user wants to send the ciphertext as a WhatsApp message.

In the event that the user passed the flag, the handle_notification() function is called. This function takes the ciphertext and the value passed to the flag, then, it filters the recipient to ensure that it is in the appropriate format, then makes a call to the send_whats_app_message() function in the notification module. This function returns a tuple, either from the send_whats_app_message() function or an error tuple which is created when the user enters an invalid phone number.

You can take your shiny new CLI for a spin by running the following commands.

cargo run encrypt Welcome to the hallowed chAmbers!  --algorithm=caesar --rotations=54
cargo run encrypt Welcome to the hallowed chambers! --algorithm=bacon
cargo run decrypt Ygneqog vq vjg jcnnqygf ejCodgtu! --algorithm=caesar --rotations=54

There you have it!

Using Seahorse, you were able to build an encryption/decryption CLI. You also learnt how to handle arguments and flags in order to add more flexibility to your commands.

There are still some other things you can do such as building and distributing a binary which you can distribute to your inner circle for easy communication and distribution of secrets.

The entire codebase is available on GitHub should you get stuck at any point. I’m excited to see what else you come up with. Until next time, make peace not war ✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends. Find him on LinkedIn, Medium, and Dev.to.