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.