Something I’ve been working on recently is a CLI application using the rust language.
Reading in settings is a common feature for most applications.
Something I was looking for was the ability to read settings from the cli and a file into a single struct.
There are a couple of libraries available to handle this
Figment does provide one example for handling this with clap
However for my purposes it tends to read the settings in the wrong order.
Normally with a CLI application you want to read settings in this order
- Start with a default setting if nothing else is specified
- Then import a setting from a configuration file (toml) if specified
- Then if the setting is specified at the command line via a argument / option override the previous two steps.
Getting things to work in this order requires a couple of tricks I’ve not seen clearly documented So I thought I’d list an example here.
Example configuration
First lets start with an example configuration file
figment_config.toml
name = "HelloWorld"
count = 2Example Code
Next lets dive into some example code
main.rs
use clap::{error::ErrorKind, CommandFactory, Parser};
use figment::{
providers::{Format, Serialized, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
use serde_default_utils::*;
use std::path::PathBuf;
/// A demo showing the use of figment with clap
#[serde_inline_default]
#[derive(Parser, Debug, Serialize, Deserialize)]
pub struct AppConfig {
/// Name of the person to greet
#[arg(short, long)]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
name: Option<String>,
/// Number of times to greet - default value of 1
#[arg(short, long)]
#[serde_inline_default(Some(1))]
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
count: Option<u8>,
}
fn main() {
let cfgpath = PathBuf::from("figment_config.toml");
let config: AppConfig = Figment::new()
.merge(Toml::file(cfgpath))
.merge(Serialized::defaults(AppConfig::parse()))
.extract()
.unwrap();
// Custom Validation
if config.name.is_none() {
let mut cmd = AppConfig::command();
cmd.error(ErrorKind::MissingRequiredArgument, "name option not found")
.exit();
}
println!("Name: {}", config.name.unwrap());
println!("Count: {}", config.count.unwrap());
}For this to work you’ll need the following dependencies
Cargo.toml
[dependencies]
clap = { version = "*", features = ["derive"] }
figment = { version = "*", features = ["toml"] }
serde = { version = "*", features = ["serde_derive"] }
serde_default_utils = { version = "*", features = ["inline"] }Example usage
With the code above the settings are loaded in the following order
- First any default values are loaded in via the serde serialization library
- Next values are read in via a toml configuration file using figment
- Finally any command line arguments override any settings at a top level via clap
If we try and run the app with the --help option then the following help message will be displayed
$ testapp --help
A demo showing the use of figment with clap
Usage: testapp1.exe [OPTIONS]
Options:
-n, --name <NAME> Name of the person to greet
-c, --count <COUNT> Number of times to greet - default value of 1
-h, --help Print helpRunning the application without any cli arguments results in
$ testapp
Name: HelloWorld
Count: 2This is because the count of 2 specified in the configuration file overrides the default value of 1.
If we pass in a cli option
$ testapp --count 4
Name: HelloWorld
Count: 4How This Works
For this to work all fields need to be optional or typed as Option<> This is to avoid clap reporting an error or missing option if an option is already set by figment or by serde’s defaults later on within the figment configuration file.
This means in practice for any required fields these need to be checked via custom validation after everything is loaded in.
// Custom Validation
if config.name.is_none() {
let mut cmd = AppConfig::command();
cmd.error(ErrorKind::MissingRequiredArgument, "name option not found")
.exit();
}Default values
In order to set defaults for fields we can use the serde_inline_default crate.
By performing this at the serde level, we can inject default values prior to them being possibly set via figment.
we use Some() due to the field needing to be an Option.
#[serde_inline_default(Some(1))]Toml values
After loading in any default the next step is to load in the toml file to override any default with values specified within a configuration file.
Figment handles this with the following
let config: AppConfig = Figment::new()
.merge(Toml::file(cfgpath))
.merge(Serialized::defaults(AppConfig::parse()))
.extract()
.unwrap();CLI values
The command arguments are loaded within figment via .merge(Serialized::defaults(AppConfig::parse()))
Something we need to handle is the fact that if clap doesn’t see an option specified at the command line
then it will potentially overwrite the setting with a None value (which is not what we want)
To avoid this we can use the following serde attribute as a workaround.
This will cause the setting to fall back to ether the configuration file or defaults
#[serde(skip_serializing_if = "::std::option::Option::is_none")]
