- Published on
Ideas for crafting CLI in Rust
- Authors
- Name
- Himanshu Neema
- @gigapotential
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.
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>] ...
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:
- Each sub-command is represented as an
enum
variant. - Arguments or data to a particular sub-command is represented as
struct
. - To execute sub-command we call
run()
method onstruct
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),
}
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>,
}
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>;
}
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
}
}
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
}
}
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.