As my journey on learning Rust continues, I looked at several options for building Command-Line Interfaces (CLIs) in Rust. There are several crates available you can use to craft tailored CLIs in Rust. At some point, StructOpt got my attention, and I saw a couple of people tweeting and writing about it. That said, this article helps you understanding StructOpt and gives you a head-start when it comes to building custom CLIs in Rust.

An introduction to StructOpt

So, for those of you who don’t know StructOpt, it is a framework that makes building CLIs in Rust easier than ever before! With it, we can use first-class citizen Rust language features like structs and enums and custom attributes to layout our user interface. We also use the structopt attribute to enrich commands, attributes, and flags with abbreviations, help-text, default values, and other features.

Under the hood, StructOpt uses the clap crate to build the command-line interface. In other words, StructOpt is a beautiful wrapper for clap.

Given a new Rust project (cargo new fundamentals), we can install StructOpt by adding it to our Cargo.toml file as shown here:

[dependencies]
structopt = "0.3.23"

To grasp the fundamentals, let’s quickly build the fundamentals CLI. To layout the “user interface”, create a new struct and derive from StructOpt and Debug as shown below:

use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt( name = "fundamentals", 
    about = "I am a simple CLI to teach you the fundamentals")]
struct CLI {
    #[structopt(long, short)]
    debug: bool,

    #[structopt(long, short, default_value = "2")]
    iterations: u8
}

fn main() {
    let i = CLI::from_args();
    println!("{:?}", i);
}

We use the #[structopt(name, about)] attribute directly on the CLI struct, to provide basic information about our CLI application.

On the other side, we use #[structopt(long, short)] on debug, which will allow users to use either --debug or -d to set the debug flag. For iterations, we also provide a default value of 2. By the way, StructOpt tries to cast values provided by the user automatically. See the corresponding section of the StructOpt documentation for further information. Obviously, we can run the fundamentals CLI using cargo run. That said, we can also pass arguments, flags, and options to our CLI. We have to prefix them with a double-dash (cargo run -- -d -i 50).

StructOpt automatically generates basic help for the CLI app. Invoke cargo run -- --help, and you should see the following help is displayed:

fundamentals 0.1.0
I am a simple CLI to teach you the fundamentals

USAGE:
    fundamentals [FLAGS] [OPTIONS]

FLAGS:
    -d, --debug      
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -i, --iterations <iterations>     [default: 2]

For the scope of this article, we will now build a simple CLI, which allows you to interact with strings in several ways, as shown in the following snippet:

## string modifications 

strings hello mod --upper
strings hello mod -u
strings "Hello, World" mod --lower
strings "Hello, World" mod -l
strings Goodbye mod --reverse
strings Goodbye mod -r
strings Hello mod -u -r --debug

## string inspection
strings hello insp --length
strings hello insp --l
strings "Hello world!11" insp --numbers
strings "Hello World!" insp --spaces --debug

Design a CLI with sub-commands

Having slightly touched the fundamental capabilities, let’s revisit the design of the sample CLI we are going to build now. The strings CLI consists of two sub-commands: mod and insp. The first is used to modify the user input, whereas the second is used to inspect the user input. Every sub-command has flags and options that users can use to create fine-granular instructions. On top of that, both sub-commands should share the global debug flag.

Last but not least, we will use pattern matching to call into the desired business logic.

So, let’s get started!

Create the root interface

First, we have to define the root interface of our strings CLI. Again, we use a simple struct, derived from StructOpt and Debug, and decorate it with the structopt attribute. The actual string provided by the user, is the positional argument, in StructOpt, we don’t have to provide any further attributes, if we want to deal with one positional attribute (see theinput field). At this point, we can also add the debug flag. We want it to be a global flag that should available to all sub-commands of our CLI app.

use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt( name = "strings",
   author = "Thorsten Hans <[email protected]>",
   about = "strings - Let's you modify and inspect strings")]
struct CLI {

    #[structopt(long, short, global = true, 
       help = "Prints debug information")]
    debug: bool,
    input: String,
}

We want our CLI to be self-explaining. That is why we have specified a custom help message for debug. Also notice global = true, which makes the debug flag available for all sub-commands, which we will create next.

Define sub-commands using an enum

Enums in Rust is really powerful. Having complete control over the “shape” of each variant is fantastic, and we use this flexibility in the context of StructOpt to create the tailored interfaces for all our sub-commands:

#[derive(Debug,StructOpt)]
enum SubCommand {
    #[structopt(name = "mod", about = "Use mod to modify strings")]
    Modify(ModifyOptions),
    #[structopt(name = "insp", about = "Use insp to inspect strings")]
    Inspect(InspectOptions)
}

#[derive(Debug,StructOpt)]
struct ModifyOptions {
    #[structopt(short, long, help = "Transforms a string to uppercase")]
    upper: bool,
    #[structopt(short, long, help = "Transforms a string to lowercase")]
    lower: bool,
    #[structopt(short, long, help = "Reverses a string")]
    reverse: bool,
    #[structopt(short="pref", long, help = "Adds a prefix to the string")]
    prefix: Option<String>,
    #[structopt(short="suf", long, help = "Adds a suffix to the string")]
    suffix: Option<String>,
}

#[derive(Debug,StructOpt)]
struct InspectOptions {
    #[structopt(short, long, help = "Count all characters in the string")]
    length: bool,
    #[structopt(short, long, help = "Count only numbers in the given string")]
    numbers: bool,
    #[structopt(short, long, help = "Count all spaces in the string")]
    spaces: bool
}

If you have paid attention to the snippet above, you may have noticed that the structopt attribute offers different capabilities depending on the context it is used in. Every variant of the enum will become a sub-command of our CLI. That said, we use structopt(name, about) in that context to ensure our code is readable while the CLI command is pretty short.

On the other side, we use structopt(short = "") on prefix and suffix to customize the abbreviation for both attributes. Speaking about prefix and suffix have you noticed that both attributes should be optional? We can enforce optional attributes in StructOpt by simply setting the type of the field from String to Option<String>. Everything else is done by StructOpt under the covers.

Having defined our global debug flag and constructed all sub-commands, it is time to wire everything up. We will add another field to our CLI struct. A field of type SubCommand. Finally, we decorate it with the structopt attribute and tell the library that it should use our custom enum to layout the sub-commands:

#[derive(Debug, StructOpt)]
#[structopt( name = "strings",
   author = "Thorsten Hans <[email protected]>",
   about = "strings - Let's you modify and inspect strings")]
struct CLI {

    #[structopt(long, short, global = true,
       help = "Prints debug information")]
    debug: bool,
    input: String,

    #[structopt(subcommand)]
    cmd: SubCommand
}

Load data form environment variables

We can also load defaults from environment variables in StructOpt. For demonstration purposes, we will extend the sample application, to load both, prefix and suffix - which have been declared as part of ModifyOptions - from corresponding environment variables STRINGS__PREFIX and STRINGS__SUFFIX. Again, we just have to update the corresponding #[structopt] attributes and add an env instruction:

#[derive(Debug,StructOpt)]
struct ModifyOptions {
    #[structopt(short, long, help = "Transforms a string to uppercase")]
    upper: bool,
    #[structopt(short, long, help = "Transforms a string to lowercase")]
    lower: bool,
    #[structopt(short, long, help = "Reverses a string")]
    reverse: bool,
    #[structopt(short="pref", long, help = "Adds a prefix to the string", env = "STRINGS__PREFIX")]
    prefix: Option<String>,
    #[structopt(short="suf", long, help = "Adds a suffix to the string", env = "STRINGS__SUFFIX")]
    suffix: Option<String>,
}

Giving it a try

Time to give it a try! Again let’s quickly implement some fundamental update the main function to print the result parsed by StructOpt:

fn modify(input: &String, debug: bool, args: &ModifyOptions) {
    println!("Inspect called for {}", input);
    if debug {
        println!("{:#?}", args);
    }
}

fn inspect(input: &String, debug: bool, args: &InspectOptions) {
    println!("Inspect called for {}", input);
    if debug {
        println!("{:#?}", args);
    }
}

fn main(){
    let args = CLI::from_args();
    match args.cmd {
        SubCommand::Inspect(opt) => {
            inspect(&args.input, args.debug, &opt);
        }
        SubCommand::Modify(opt) => {
            modify(&args.input, args.debug, &opt);
        }

    } 
}

Fire up your terminal and try some of the commands, flags, and attributes we have just specified. You should see the parsed instance of CLI being pretty-printed to the terminal:

cargo run -- Hello mod -u -d --reverse --suffix scnr

Inspect called for Hello
ModifyOptions {
    upper: true,
    lower: false,
    reverse: true,
    prefix: None,
    suffix: Some(
        "scnr",
    ),
}

cargo run -- foo insp -l -n -d

Inspect called for foo
InspectOptions {
    length: true,
    numbers: true,
    spaces: false,
}

STRINGS__PREFIX=4 cargo run -- 2 mod -u -d

Inspect called for 2
ModifyOptions {
    upper: true,
    lower: false,
    reverse: false,
    prefix: Some(
        "4",
    ),
    suffix: None,
}

Conclusion

Building CLI applications using StructOpt feels straightforward. It allows you to divide presentation code from the actual business logic strictly. Encapsulating the parser entirely into the StructOpt trait makes dealing with many flags and attributes super simple.

I could layout the structure for different CLIs in almost no time and could immediately focus on implementing the domain logic.

However, if I have to compare it with other CLI frameworks like, for example, cobra - which is the defacto standard for building CLI applications in Go - I think StructOpt needs a bit more upfront thinking and modeling the layout of the CLI commands.