DEV Community

Himanshu Neema
Himanshu Neema

Posted on

Ideas for crafting CLI in Rust

I'm a big fan of Rust. My first real world usage of Rust was a cli I created for a hobby project.

In this article I want to share few ideas I used to create cli called upvpn for UpVPN to mange Serverless VPN sessions.

Image description

Idea: Subcommand Pattern

A lot of simple clis have following pattern: A cli with many sub-commands and each of the sub-command having its own arguments:

<cli-name> <sub-command> [<arg>] ...
Enter fullscreen mode Exit fullscreen mode

Overtime we want to be able to add sub-command easily.

To do so, lets create a mental model of adding a new sub-command to our cli:

  1. Each sub-command is represented as an enum variant.
  2. Arguments or data to a particular sub-command is represented as struct.
  3. To execute sub-command we call run() method on struct holding all the arguments.

Lets expand each point in detail:

Point #1 and #2

First two are simple use cases of clap.rs:

use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Cli {
    #[clap(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Sign in to your https://upvpn.app account
    SignIn(SignIn),
    /// Sign out current device
    SignOut(SignOut),
    /// Current VPN status
    Status(Status),
    /// Available locations for VPN
    Locations(ListLocations),
    /// Connect VPN
    Connect(Connect),
    /// Disconnect VPN
    Disconnect(Disconnect),
}
Enter fullscreen mode Exit fullscreen mode

Here each of the variants SignIn, Locations etc. will be available as sub-command on cli with lower case. And their arguments are stored as data in the struct fields.

For instance, SignIn has optional argument to hold email of the user:

[derive(Args, Debug)]
pub struct SignIn {
    email: Option<String>,
}
Enter fullscreen mode Exit fullscreen mode

Point #3

We define a trait RunCommand for each of the data structs to derive, so that we can execute the command by simply calling run() on it.

#[async_trait]
pub trait RunCommand {
    async fn run(self) -> Result<(), CliError>;
}
Enter fullscreen mode Exit fullscreen mode

For instance, SignIn will be implemented it like this:

[derive(Args, Debug)]
pub struct SignIn {
    email: Option<String>,
}

#[async_trait]
impl RunCommand for SignIn {
    async fn run(self) -> Result<(), CliError> {
       // todo: implement sign in logic.
       // arguments to cli available here as data fields on self
    }
}
Enter fullscreen mode Exit fullscreen mode

Having this trait makes main driver of whole cli very simple:

impl Cli {
    pub async fn run(self) -> ExitCode {
        let output = match self.command {
            Commands::SignIn(sign_in) => sign_in.run().await,
            Commands::SignOut(sign_out) => sign_out.run().await,
            Commands::Locations(list_locations) => list_locations.run().await,
            Commands::Connect(connect) => connect.run().await,
            Commands::Disconnect(disconnect) => disconnect.run().await,
            Commands::Status(status) => status.run().await,
        };

        // process error in output to return exit code
     }
}

Enter fullscreen mode Exit fullscreen mode

Idea: Error messages for user

Having RunCommand trait with single method with return type Result<(), CliError> has another benefit, because now we only need to implement Display for CliError for displaying errors in a user friendly way.

thiserror crate makes it easy to do so using macros on CliError enum.

Idea: Graceful Termination

Certain cli operations may be long running in nature, in this situation cli user should be able to exit it gracefully by SIGTERM or ctrl+c.

To handle it would require a dedicated async task (or dedicated thread) to listen to these system events and notify rest of the system through oneshot or broadcast channels, or as simple as printing a message and exiting whole process by std::process::exit(code).

Tokio's doc on the Shutdown topic is very informative.

Idea: Configuration

Cli can take its global configuration from files (json, toml), environment variables or even combination of them.

Figment makes merging configuration from various sources very easy.

Idea: Progress, Colors and User Input

Colors and Progress of a cli not only make cli pleasant to use, but can convey important information to user about errors, success, and progress of long running operations.

When asking user input from list of many options, make it easy to filter by typing few characters through dialoguer::FuzzySelect.

When asking user for password, keep it stay hidden using dialoguer::Password

dialoguer, indicatif, and console are your best friends for a good cli UX.

That's all! For more, checkout this book on building cli in Rust.

To see it all together, checkout cli code in upvpn-app Github repository.

Top comments (0)