Rust Clap recipes
Pavlo Myroniuk June 15, 2023 #tool #recipes #rustI bet you know, but I still wanna recall it. Clap is a full-featured, fast Command Line Argument Parser for Rust. docs.rs. crates.io.
Goals
Goals:
- Show non-standard examples of the cli arguments configuration.
- Tell you about alternate ways to parse cli args with the
clap.
Non-goals:
- Write the "ultimate" guide to the
claplibrary. - Create an introduction (or for newbies) article about the clap.
The current article is basically a recipe. It means that here we have described concrete problems, ways to solve them, and examples of their execution.
Recipes
Collecting multiple values
Imagine the following situation: you are writing some server. It can be the server-side of some protocol, a proxy server, or something like that. At some point, you decided to add encryption. Now you need to add the ability for the user to pass allowed encryption algorithms (ciphers) for server sessions.
Let's try to do it. The first idea that comes to mind is just to use a vector of strings:
/// Server config structure
#[derive(Parser, Debug)]
struct Config {
/// Allowed encryption algorithms list
#[arg(long, value_name = "ENCRYPTION ALGORITHMS")]
pub enc_algs: Vec<String>,
}
Note 1: personally, I prefer the derive approach to configure clap instead of a builder approach.
Note 2: For all examples in this article, I use the same main function:
/// Just prints the server configuration.
/// `Config` is a previously defined structure (like in the example above).
fn main() {
println!("{:?}", Config::parse());
}
It'll work. Here is an example:
./cool-server --enc-algs aes256 --enc-algs des3 --enc-algs thebesttvarynka
# Config { enc_algs: ["aes256", "des3", "thebesttvarynka"] }
Good, but here we have a big problem: values are not validated, and the user can specify unsupported or just wrong algorithms. Let's delegate algorithm validation to clap.
First, we need to implement the Cipher enum. Most likely, you'll have such an enum already implemented in the project, but here we need to write it:
#[derive(Debug, Clone, clap::ValueEnum)]
pub enum Cipher {
Aes128,
Aes256,
Des3,
}
// The next traits implementations are omitted
// and left for the reader as a homework 😝
//
// impl AsRef<str> for Cipher { ... }
// impl Display for Cipher { ... }
// impl TryFrom<&str> for Cipher { ... }
Pay attention that we also added the clap::ValueEnum derive. It'll generate the ValueEnum trait implementation, and clap will be able to parse the raw string into a concrete enum value. Now time to test it:
./cool-server --enc-algs aes256 --enc-algs des3
# Config { enc_algs: [Aes256, Des3] }
./cool-server --enc-algs aes256 --enc-algs des3 --enc-algs thebesttvarynka
# error: invalid value 'thebesttvarynka' for '--enc-algs <ENCRYPTION ALGORITHMS>'
# [possible values: aes128, aes256, des3]
#
# For more information, try '--help'.
Very cool 🔥. Clap validates the input for us and even tells the user possible values if smth goes wrong. You can read all code of the example above here.
In general, we can finish this recipe at this point, but I used to specify multiple values using a comma-separated string. The perfect arg looks to me like this:
./cool-server --enc-algs aes256,des3
# or even
./cool-server --enc-algs aes256:des3
"This move will cost us" some additional code writing 🥲. We need to create a wrapper over the Vec<Cipher> and implement parsing for it:
#[derive(Debug, Clone)]
struct EncAlgorithms(pub Vec<Cipher>);
// Implementation of this trait is left for the reader.
// impl fmt::Display for EncAlgorithms { ... }
fn parse_encryption_algorithms(raw_enc_algorithms: &str) -> Result<EncAlgorithms, Error> {
let mut parsed_enc_algs = Vec::new();
for raw_enc_alg in raw_enc_algorithms.split(':').filter(|e| !e.is_empty()) {
parsed_enc_algs.push(raw_enc_alg.try_into()?);
}
if parsed_enc_algs.is_empty() {
return Err(Error::new(ErrorKind::InvalidValue));
}
Ok(EncAlgorithms(parsed_enc_algs))
}
And only after this, we can use it in our configuration:
/// Server config structure
#[derive(Parser, Debug)]
struct Config {
/// Allowed encryption algorithms list (separated by ':').
#[arg(
long,
value_name = "ENCRYPTION ALGORITHMS LIST",
// https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser
value_parser = ValueParser::new(parse_encryption_algorithms),
default_value_t = EncAlgorithms(vec![Cipher::Aes256, Cipher::Aes128]),
)]
pub enc_algs: EncAlgorithms,
}
Why should we create the wrapper? Why is custom parser +
Vec<Cipher>not enough?
Yes, you can write just Vec<Cipher> with custom value_parser and it'll compile. But it'll fail at runtime with the following error:
thread 'main' panicked at 'Mismatch between definition and access of `enc_algs`. Could not downcast to cool_server::cipher::Cipher, need to downcast to alloc::vec::Vec<cool_server::cipher::Cipher>
', src/main.rs:37:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Understandable.
Let's try to run the implementation with a wrapper:
./cool-server --enc-algs aes256:des3
# Config { enc_algs: EncAlgorithms([Aes256, Des3]) }
./cool-server --enc-algs aes256:des3:thebesttvarynka
# error: invalid value 'aes256:des3:thebesttvarynka' for '--enc-algs <ENCRYPTION ALGORITHMS LIST>': error: invalid value 'Invalid algorithm name: thebesttvarynka' for 'enc-algs'
#
# For more information, try '--help'.
./cool-server --help
# Server config structure
#
# Usage: cool-server [OPTIONS]
#
# Options:
# --enc-algs <ENCRYPTION ALGORITHMS LIST>
# Allowed encryption algorithms list (separated by ':') [default: aes256:aes128]
# -h, --help
# Print help
The full src code of the example above.
Okay, but are you sure that we can't do better? I am still thinking about a better solution.
IDK 😕. Let's explore the docs more thoroughly... Oh, it turns out it's called the value_delimiter:
/// Server config structure
#[derive(Parser, Debug)]
struct Config {
/// Allowed encryption algorithms list (separated by ':' or space).
#[arg(
long,
value_name = "CIPHERS",
default_values_t = vec![Cipher::Aes256],
value_delimiter = ':',
num_args = 1.., // at least one allowed cipher type
)]
pub enc_algs: Vec<Cipher>,
}
Good job. It's way better now.
Yep. With this approach, we remove all custom parsing and formatting stuff. Moreover, the help message and error reporting became better:
./cool-server --help
# Server config structure
#
# Usage: cool-server [OPTIONS]
#
# Options:
# --enc-algs <CIPHERS>... Allowed encryption algorithms list (separated by ':' or space) [default: aes256] [possible values: aes128, aes256, des3]
# -h, --help Print help
./cool-server --enc-algs aes256:des3
# Config { enc_algs: [Aes256, Des3] }
./cool-server --enc-algs aes256:des4
# error: invalid value 'des4' for '--enc-algs <CIPHERS>...'
# [possible values: aes128, aes256, des3]
#
# tip: a similar value exists: 'des3'
#
# For more information, try '--help'.
The full src code of the example above.
Commands and arg groups
Before we start this recipe, I wanna recall one thing: the difference between commands and args. First of all, commands don't contain any dashes or slashes in their names. They are just words. Commands specify what to do, whereas args specify how to do it. Example:
gcloud auth login --cred-file creds.json
# What to do? `login`.
# How to do it? By taking a file with creds named `creds.json`.
In this recipe, we'll work with commands and args. You'll see a more complex example of the clap configuration.
Interesting. What do we need to configure? 😄
We all know the Imgur site (if not, then just visit). It has an API. Let's imagine we decided to write a CLI tool to help us work with the Imgur site using its API. So, now we need to design the tool interface. We do not plan to cover the whole API. Just downloading and uploading. A quick draft configuration:
#[derive(Debug, Clone, Subcommand)]
enum Command {
Upload,
Download,
}
/// Img tool config structure
#[derive(Parser, Debug)]
struct Config {
/// command to execute
#[command(subcommand)]
pub command: Command,
/// Path to the api key file
#[arg(long, env = "API-KEY")]
pub api_key: PathBuff,
}
We immediately have a few interesting moments: the Subcommand trait derive and the PathBuff type in the api_key field. We are not forced to use only simple types for args like Strings, numbers, etc. If you have a concrete type that describes your value (PathBuff for file path, url::Url for URLs, or even custom ones), then use this type in the configuration. It'll handle more errors during parsing and make further work easier.
Now we add params for downloading the command:
#[derive(Debug, Clone, Subcommand)]
enum Command {
Upload,
Download {
/// Source image link
#[arg(long)]
link: Url, // <--- Pay attention to the type. We use the `Url` here and not the `String`.
/// Path for the image
#[arg(long)]
dest_file: PathBuf,
}
}
To download the image, we need only two things: the source image link and the destination file path. This is how it works:
./img-tool download --help
# Usage: img-tool --api-key <API_KEY> download --link <LINK> --dest-file <DEST_FILE>
#
# Options:
# --link <LINK> Source image link
# --dest-file <DEST_FILE> Path for the image
# -h, --help Print help
./img-tool --api-key key.json download --link https://imgur.com/gallery/vNOUshX --dest-file ferris.png
# Config { command: Download { link: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("imgur.com")), port: None, path: "/gallery/vNOUshX", query: None, fragment: None }, dest_file: "ferris.png" }, api_key: "key.json" }
./img-tool --api-key key.json download --link :imgur.com/gallery/vNOUshX --dest-file ferris.png
# error: invalid value ':imgur.com/gallery/vNOUshX' for '--link <LINK>': relative URL without a base
#
# For more information, try '--help'.
Cool and pretty simple. But the upload is a little bit more complex. We wanna have two options to take the photo to upload: file image on the device or any public URL on the Internet. Here is the configuration for the upload command:
#[derive(Debug, Clone, Args)]
#[group(required = true, args = ["file", "link"])]
/// Possible types of the file source
struct FileSource {
/// Path to the image on the device
#[arg(long)]
file: Option<PathBuf>,
/// Url to the image on the Internet
#[arg(long)]
link: Option<Url>,
}
#[derive(Debug, Clone, Subcommand)]
enum Command {
Upload {
/// File to upload
#[command(flatten)]
file_source: FileSource,
/// Folder for the image on the site
#[arg(long)]
folder: String,
},
Download { /* omitted */ },
}
Okay, we have two file sources: a file or a link. And we use ArgGroup to require the user to specify only one of them: either file or link. And here is the demo:
./img-tool upload --help
# Possible types of the file source
#
# Usage: img-tool --api-key <API_KEY> upload --folder <FOLDER> <--file <FILE>|--link <LINK>>
#
# Options:
# --file <FILE> Path to the image on the device
# --link <LINK> Url to the image on the Internet
# --folder <FOLDER> Folder for the image on the site
# -h, --help Print help
./img-tool --api-key key.json upload --folder ferris --file crab_ferris.png
# Config { command: Upload { file_source: FileSource { file: Some("crab_ferris.png"), link: None }, folder: "ferris" }, api_key: "key.json" }
./img-tool --api-key key.json upload --folder ferris --link https://i.imgflip.com/7gq1em.jpg --file crab_ferris.png
# error: the argument '--file <FILE>' cannot be used with '--link <LINK>'
#
# Usage: img-tool --api-key <API_KEY> upload --folder <FOLDER> <--file <FILE>|--link <LINK>>
#
# For more information, try '--help'.
./img-tool --api-key key.json upload --folder ferris --link https://i.imgflip.com/7gq1em.jpg
# Config { command: Upload { file_source: FileSource { file: None, link: Some(Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("i.imgflip.com")), port: None, path: "/7gq1em.jpg", query: None, fragment: None }) }, folder: "ferris" }, api_key: "key.json" }
Another interesting thing: we can see the separated --file and --link args in the help message by the <> triangle brackets. It shows the user that only one of them is needed. Cool, right? 😎
The full src code of the example above.
Parsing args into a custom structure
This recipe will be smaller than the previous ones and similar to the second one. But I just want to show that we can do such tricks.
We are going to improve the previous example by adding a more flexible way to pass the API key. Assume that for authentication we need two tokens: app id and app secret. And we want to operate them as one structure. Usually, in such cases, developers treat them as two separate strings and then create a single structure based on those strings. But we are smarter and know how to use commands:
#[derive(Debug, Clone, Args)]
struct ApiKey {
/// app id
#[arg(long)]
pub api_app_id: String,
/// app secret
#[arg(long)]
pub api_app_secret: String,
}
/// Img tool config structure
#[derive(Parser, Debug)]
struct Config {
/// command to execute
#[command(subcommand)]
pub command: Command,
/// API key data
#[command(flatten)]
pub api_key: ApiKey,
}
Let's test it and see the trick:
./img-tool --help
# Img tool config structure
#
# Usage: img-tool --api-app-id <API_APP_ID> --api-app-secret <API_APP_SECRET> <COMMAND>
#
# Commands:
# upload Upload image to the Imgur
# download
# help Print this message or the help of the given subcommand(s)
#
# Options:
# --api-app-id <API_APP_ID> app id
# --api-app-secret <API_APP_SECRET> app secret
# -h, --help Print help
We have two separate args api-app-is and api-app-secret, but they will be parsed and placed in the one ApiKey structure. It's very convenient and we can continue to work with the auth data as one structure without any additional actions.
The full src code of the example above.
Unsolvable problem
This section is not an actual recipe. I'll tell you about a problem that doesn't have a perfect solution yet (or I just cannot find one).
Let's take the previous recipe and make the task more difficult. Assume that the user should specify the app id and secret OR API key file. In other words, the user should provide the one --api-key-file arg or --api-app-id and --api-app-secret args. It means one OR two arguments.
I found a few similar questions on the Internet:
But neither of them had any useful answers. So far, the following code is the best way we can solve this problem:
#[derive(Debug, Clone, Args)]
struct ApiKeyData {
/// app id
#[arg(long, requires = "api_app_secret")]
pub api_app_id: Option<String>,
/// app secret
#[arg(long, requires = "api_app_id")]
pub api_app_secret: Option<String>,
}
#[derive(Debug, Clone, Args)]
#[group(required = true, args = ["api_key_file", "api_app_id", "api_app_secret"])]
/// Possible types of the api key source
struct ApiKey {
/// Path to the json file with API key
#[arg(long)]
api_key_file: Option<PathBuf>,
/// Specify API key data in args
#[command(flatten)]
api_key_data: ApiKeyData,
}
// The `Config` structure remains unchanged since the last recipe
The main idea is to create an ArgGroup and require additional args in the ApiKeyData structure if one of the needed args is not specified. This approach has a lot of big inconveniences:
- Fields in the
ApiKeyDatastructure are optional. Yes, during parsing, they are validated, and 100% have values, but for further work, we are forced to unwrap them and create another structure. - The help message for the user is not fully informative. It shows that among those three arguments, only one is required. That is a lie because we need a key file OR app id + secret.
Enough talking. Let's see it in action:
./img-tool --help
# Img tool config structure
#
# Usage: img-tool <--api-key-file <API_KEY_FILE>|--api-app-id <API_APP_ID>|--api-app-secret <API_APP_SECRET>> <COMMAND>
#
# Commands:
# upload Upload image to the Imgur
# download
# help Print this message or the help of the given subcommand(s)
#
# Options:
# --api-key-file <API_KEY_FILE> Path to the json file with API key
# --api-app-id <API_APP_ID> app id
# --api-app-secret <API_APP_SECRET> app secret
# -h, --help Print help
./img-tool --api-key-file key.json download --link https://imgur.com/gallery/vNOUshX --dest-file ferris.png
# Config { command: Download { link: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("imgur.com")), port: None, path: "/gallery/vNOUshX", query: None, fragment: None }, dest_file: "ferris.png" }, api_key: ApiKey { api_key_file: Some("key.json"), api_key_data: ApiKeyData { api_app_id: None, api_app_secret: None } } }
./img-tool --api-app-id tbt --api-app-secret secret download --link https://imgur.com/gallery/vNOUshX --dest-file ferris.png
# Config { command: Download { link: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("imgur.com")), port: None, path: "/gallery/vNOUshX", query: None, fragment: None }, dest_file: "ferris.png" }, api_key: ApiKey { api_key_file: None, api_key_data: ApiKeyData { api_app_id: Some("tbt"), api_app_secret: Some("secret") } } }
./img-tool --api-app-id tbt download --link https://imgur.com/gallery/vNOUshX --dest-file ferris.png
# error: the following required arguments were not provided:
# --api-app-secret <API_APP_SECRET>
#
# Usage: img-tool <--api-key-file <API_KEY_FILE>|--api-app-id <API_APP_ID>|--api-app-secret <API_APP_SECRET>> <COMMAND>
#
# For more information, try '--help'.
The full src code of the example above. Conclusion: this problem is solvable, but in a very inconvenient way.
References & final note
The end. The official reference has all you need.
As you can see, the clap gives us a lot of opportunities to configure args parsing and validation. Sometimes it's really worth it to read the docs or references. Advice for the future: try to move as much work as you can to the clap. It's a very powerful tool, so you shouldn't be bothered by manual parsing or validation of the args.