DEV Community

Shuttle
Shuttle

Posted on • Originally published at shuttle.rs

Getting Started with CLI tools in Rust using Clap

When it comes to learning Rust, often the first real program you'll make might be a command-line interface (CLI) application. Although command-line tools are quite common, they are also a good way to practice learning the basics of a language, and this is no less true in Rust. Crates like clap make it super easy to write your own CLI tool in Rust by making it as easy as possible using structs and macros via the Derive feature, as well as offering a more low-level option with the Builder API.

In this article, we'll be looking at how you can get started with the clap Rust crate and write a versatile Rust CLI, crates that synergise well with clap as well as real-world use cases.

Getting Started

First of all, let's initialise our project by using cargo init example-cli. We can then add clap to our program by running the following command:

cargo add clap -F derive
Enter fullscreen mode Exit fullscreen mode

We'll primarily be going through using the derive feature as it is generally much simpler to use to get what you want.

Baby's First Clap

To get started, we'll want to use the following code in our src/main.rs:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
    name: String
}

fn main() {
    let cli = Cli::parse();

    println!("Hello, {}!", cli.name);
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we are using a struct that uses the clap::Parser derive macro, which automatically generates all of the functions that we need to be able to use the struct as a parser. Initially when we load the program up and use Cli::parse(), it will take the arguments from whatever is in std::env::os - our environment arguments that we have given it.

Our program takes one argument at the moment, which is name. If you try running cargo run test, it should print out "Hello, test!" - but if you try to run it without any extra values, it will print the help menu. Clap has a help feature included in the default features that automatically displays the help menu when no valid commands are entered, which saves us a lot of time! Let's try adding a flag to it so that if you don't add anything, it will print a default value:

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
    #[arg(short, long, default_value = "shuttle")]
    name: String
}
Enter fullscreen mode Exit fullscreen mode

Now if you try using cargo run without anything else, it should print "Hello, shuttle!".

There are lots of different things you can add to augment your CLI, which you can find more about here.

Writing Clap Subcommands

Now for your first (sub)command! With using the derive feature in clap, all we need to do is declare some structs that use clap's macros:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
    #[command(subcommand)]
    cmd: Commands
}

#[derive(Subcommand, Debug, Clone)]
enum Commands {
    Get,
    Set
}

fn main() {
    let cli = Cli::parse();

    println!("{:?}", cli);
}
Enter fullscreen mode Exit fullscreen mode

Currently, this program will take two different commands - which are "get" and "set". If we run cargo run get now, we should receive the debug printout of what was parsed from when we ran the program. The same would be the same if you run cargo run set - if you attempt to run the program without outputting any commands, or if you try to input an invalid command, clap should return the help menu as mentioned before.

Adding Clap Command Flags

We can also use tuple-like struct syntax and named-field struct syntax for enum variants within our enum; this is because unlike in other OOP languages, Rust enums are actually sum types. You can read more about how powerful Rust enums are in another article we wrote here. You can have optional arguments by simply wrapping the types in Option, but if you want to add a flag to a command you can use bool, since clap recognises that flags are either there or not there. Let's have a look at what this might look like:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
    #[command(subcommand)]
    cmd: Commands
}

#[derive(Subcommand, Debug, Clone)]
enum Commands {
    Get(String),
    Set {
        key: String,
        value: String,
        is_true: bool
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see above, our get command now takes a non-optional argument of a String, and our set command now takes a key value and a string value - necessarily speaking though when we're running our program, we won't have to provide the keys themselves: we just need to provide the values that we want to use!

Let's have a look at what this would look like for our main function:

fn main() {
    let cli = Cli::parse();

    match cli.cmd {
        Commands::Get(value) => get_something(value),
        Commands::Set{key, value, is_true} => set_something(key, value, is_true)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now if we were to run this again by using for example cargo run get foo, it should work. For the set command, the key and value arguments are mandatory but for the --is-true flag, it will only be set to true if you add the flag - otherwise, it will be set to false.

Clap CLI Looping

Most of the time you might only want to execute a command once, but there may be times where you want to create a CLI where the user may want to keep the process running in case they want to run extra commands. For example, a REPL (also known as a "language shell" or read-eval-print-loop) will want to be able to store variables and execute statements. For this purpose, the clap::Parser trait also has the try_parse_from function where it will try to parse CLI commands from a variable that implements Into<Iterator> - for this case, we can use a vector as they can be iterated on.

Let's have a look at the Rust code below:

fn main() {
    loop {
        let mut buf = String::from(crate_name!());

        std::io::stdin().read_line(&mut buf).expect("Couldn't parse stdin");
        let line = buf.trim();
        let cli = shlex::split(line).ok_or("error: Invalid quoting").unwrap();

        println!("{:?}" , cli);

        match Args::try_parse_from(args.iter()).map_err(|e| e.to_string()) {
            Ok(cli) => {
                match cli.cmd {
                    Commands::Get(value) => get_something(value),
                    Commands::Set{key, value, is_true} => set_something(key, value, is_true)
               }
            }
            Err(_) => println!("That's not a valid command!");
       };
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see above, we first initialise a loop that starts with a String from the crate name itself and not a completely new string; without this, whenever you try to use a command, clap will error out because using it this way requires you to input the crate name. Then we expect some kind of input from the user, and once the user passes in a command we try to parse our Args type from the input. If it's successful we can move on with matching the command, if not then it uses an error.

There is a somewhat small issue with this in that you're trying to parse the arguments this way and you write an invalid command, it doesn't automatically show you the help menu - which means we will need to re-implement our own. You can do this by writing a function that prints out all of the commands:

fn show_commands() {
    println!(r#"COMMANDS:
get <KEY> - Gets the value of a given key and displays it. If no key given, retrieves all values and displays them.
set <KEY> <VALUE> - Sets the value of a given key.
    Flags: --is-true
"#);
}
Enter fullscreen mode Exit fullscreen mode

Then you can add this to the error handling instead of only writing that the command is invalid and leaving a potential user confused! You can also, of course, add a help command that displays this instead of displaying it every time an incorrect command is entered and then refer the user to it. The full CLI program would look like this:


#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
    #[command(subcommand)]
    cmd: Commands
}

#[derive(Subcommand, Debug, Clone)]
enum Commands {
    Get(String),
    Set {
        key: String,
        value: String,
        is_true: bool
    },
    Help
}

fn main() {
    loop {
        let mut buf = String::from(crate_name!());

        std::io::stdin().read_line(&mut buf).expect("Couldn't parse stdin");
        let line = buf.trim();
        let cli = shlex::split(line).ok_or("error: Invalid quoting").unwrap();

        println!("{:?}" , cli);

        match Args::try_parse_from(args.iter()).map_err(|e| e.to_string()) {
            Ok(cli) => {
                match cli.cmd {
                    Commands::Get(value) => get_something(value),
                    Commands::Set{key, value, is_true} => set_something(key, value, is_true),
                    Commands::Help => show_commands(),
                }
            }
            Err(_) => println!("That's not a valid command - use the help command if you are stuck.");
         };
    }
}

Enter fullscreen mode Exit fullscreen mode

Extending Clap

Although clap is a great tool by itself, using it by itself can be a bit barebones. Thankfully there are quite a lot of crates within the terminal/command-line crate ecosystem - you can add crates for making prompts, colouring your terminal, and much more. Here is a list of a few of our favourites that may prove quite helpful to you for writing a command-line tool in Rust:

Crossterm

crossterm is a library crate that aims to be a pure Rust terminal manipulation library. It comes with all kinds of cool stuff like being able to change background and text colors, manipulating the terminal itself and the cursor as well as capturing keyboard and other events. Crossterm is also the backbone of many, many popular other crates!

comfy-table

comfy-table is a crate designed to facilitate tables that look pretty in the terminal. You can get started in as little as this:

use comfy_table::Table;

fn main() {
    let mut table = Table::new();
    table
        .set_header(vec!["Header1", "Header2", "Header3"])
        .add_row(vec![
            "This is a text",
            "This is another text",
            "This is the third text",
        ])
        .add_row(vec![
            "This is another text",
            "Now\nadd some\nmulti line stuff",
            "This is awesome",
        ]);

    println!("{table}");
}
Enter fullscreen mode Exit fullscreen mode

When you add the above code to a program and run it, the output would look like this:

+----------------------+----------------------+------------------------+
| Header1              | Header2              | Header3                |
+======================================================================+
| This is a text       | This is another text | This is the third text |
|----------------------+----------------------+------------------------|
| This is another text | Now                  | This is awesome        |
|                      | add some             |                        |
|                      | multi line stuff     |                        |
+----------------------+----------------------+------------------------+
Enter fullscreen mode Exit fullscreen mode

Pretty easy, right?

Crates don't have to do everything: they just have to be great at one thing in particular, and comfy-table is designed to be just that.

inquire

inquire is a crate designed for building interactive prompts on the terminal. It supports single-select, multi-select, calendar picking, and more:

let name = Text::new("What is your name?").prompt();

match name {
    Ok(name) => println!("Hello {}", name),
    Err(_) => println!("An error happened when asking for your name, try again later."),
}
Enter fullscreen mode Exit fullscreen mode

If you don't want a looping CLI but your program needs more than a couple of inputs, you may want to try this out!

Clap in Action

If you're stuck with getting a high-level view of how clap can be used in production, here are a couple of repositories where you can look for inspiration!

cargo-shuttle

cargo-shuttle is Shuttle's own CLI for interacting with the Shuttle platform. Within the src folder, you will be able to get a better sense of how you can organise your folders/files for a larger CLI project for a live service. There is also use of async here with tokio, so if you're interested in learning how to get started with using clap with async services (for example setting up an async client for a database service), this would be a perfect opportunity to learn to do so!

git-cliff

git-cliff is a terminal tool that can generate changelog from the Git history by using conventional commits, as well as by using regex-powered parsers and you can even change the changelog template itself by using a configuration file. This tool is a great example of text parsing on the terminal and also uses clap_mangen which generates man pages. Useful for anyone who is serious about looking into making a production-ready terminal tool!

Finishing Up

Thanks for reading! Writing a CLI tool in Rust can be a great first step into learning the language - but that doesn't mean you can't also make great production-grade tools in the command line. Hopefully, this article has given you some insight into how you can improve any of the CLI tools you've made, or perhaps help you write a new Rust clap application.

Interested in more?

Top comments (3)

Collapse
 
matijasos profile image
Matija Sosic

Amazing intro to Rust and Clap!

Collapse
 
vladignatyev profile image
Vladimir Ignatev

Clap is a really sweet library for creating type-safe and correct CLI for your Rust programs.

I love to use Clap for web server applications especially written with Rocket.rs. Clap helps making "12 factor" apps and mostly avoid any hard-coded configurations.

It is a must have tools in your leatherbag, but a bit heavy when you try to use it for simple pet projects.

Collapse
 
try profile image
try learn

thank you!