Config files can be loaded and used in conjunction with command line opts. Config file loading is behind features for JSON and TOML loading. Config files can be used to automatically provide defined CLI opts as well as arbitrary data, all of which can be retrieved from the get_value functions in the Context given to handler functions.

This commit is contained in:
Joe Bellus 2021-06-21 01:24:32 +00:00
parent 7f757c76f9
commit 7c64993a07
10 changed files with 436 additions and 72 deletions

View File

@ -3,4 +3,6 @@ image: "rust:latest"
test:cargo:
script:
- rustc --version && cargo --version # Print version info for debugging
- cargo test --workspace --verbose
- cargo test --workspace
- cargo test --workspace --features config_toml
- cargo test --workspace --features config_json

15
Cargo.lock generated
View File

@ -8,6 +8,9 @@ version = "0.1.0"
dependencies = [
"console",
"criterion",
"serde",
"serde_json",
"toml",
]
[[package]]
@ -469,6 +472,9 @@ name = "serde"
version = "1.0.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_cbor"
@ -542,6 +548,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "toml"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa"
dependencies = [
"serde",
]
[[package]]
name = "ucd-trie"
version = "0.1.3"

View File

@ -9,6 +9,14 @@ edition = "2018"
[dependencies]
# indicatif = "0.15.0"
console = "0.14.0"
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true}
toml = { version="0.5.8", optional = true}
[features]
config = ["serde"]
config_toml = ["toml", "config"]
config_json = ["serde_json", "config"]
[dev-dependencies]
criterion = "0.3"
@ -16,3 +24,4 @@ criterion = "0.3"
[[bench]]
name = "arg_parsing"
harness = false

View File

@ -10,15 +10,12 @@ fn parse_args(c: &mut Criterion) {
"c.json".into(),
"thing".into(),
];
let app = App::new()
let mut app = App::new()
.opt(Opt::scalar("user").short("u").long("user"))
.command(
Command::new("thing")
.opt(Opt::scalar("config").short("c").long("config"))
.handler(|_, ctx, _| {
assert_eq!(ctx.get_value("user"), Some("joe".into()));
assert_eq!(ctx.get_value("config"), Some("c.json".into()));
}),
.handler(|_, _ctx, _| {}),
);
b.iter(|| {

23
examples/config.rs Normal file
View File

@ -0,0 +1,23 @@
use arkham::{App, Command, Context, Opt};
fn main() {
App::new()
.name("Config Test Example")
.config_filename("examples/config.toml")
.command(
Command::new("hello")
.opt(Opt::scalar("name").short("n").long("name"))
.short_desc("Prints a hello message with a passed name")
.handler(hello),
)
.run()
.unwrap();
}
fn hello(_: &App, ctx: &Context, _args: &[String]) {
println!(
"Hello, {}",
ctx.get_string("name")
.unwrap_or_else(|| "unknown".to_string())
);
}

1
examples/config.toml Normal file
View File

@ -0,0 +1 @@
name = "Alice Alisson"

View File

@ -21,7 +21,7 @@ fn main() {
fn fibonacci_handler(_app: &App, ctx: &Context, _args: &[String]) {
let v = fibonacci(
ctx.get_value("count")
ctx.get_string("count")
.unwrap_or("1".to_string())
.parse()
.unwrap_or(1),

View File

@ -1,6 +1,8 @@
use crate::print_command_help;
use super::command::{help, Command, Handler};
use super::context::Context;
use super::opt::{ActiveOpt, Opt, OptError, OptKind};
use super::opt::{Opt, OptError, OptKind};
use std::env;
@ -10,6 +12,7 @@ pub struct App {
name: Option<&'static str>,
version: Option<&'static str>,
pub root: Command,
config_filename: Option<&'static str>,
env_prefix: Option<&'static str>,
}
@ -20,6 +23,7 @@ impl Default for App {
version: None,
root: Command::new("root"),
env_prefix: None,
config_filename: None,
}
}
}
@ -143,16 +147,20 @@ impl App {
/// ```rust
/// use arkham::{App, Command, Context, Opt};
/// App::new()
/// .opt(Opt::flag("name").short("n").long("name"))
/// .opt(Opt::scalar("name").short("n").long("name"))
/// .handler(my_handler)
/// .run_with(vec!["-n".to_string(), "alice".to_string()]);
///
/// fn my_handler(app: &App, ctx: &Context, args: &[String]) {
/// println!("Hello, {}", ctx.get_value("name").unwrap());
/// println!("Hello, {}", ctx.get_string("name").unwrap());
/// }
/// ```
pub fn run_with(&self, args: Vec<String>) -> Result<()> {
run_command(self, &self.root, &args, &mut vec![])
pub fn run_with(&mut self, args: Vec<String>) -> Result<()> {
let mut ctx = Context::new(self.root.clone());
if let Some(filename) = self.config_filename {
ctx.load_config_file(filename);
}
run_command(self, &self.root, &args, &mut ctx)
}
/// Execute the app and any specified handlers based on the arguments passsed to the
@ -168,7 +176,7 @@ impl App {
/// .run();
///
/// fn my_handler(app: &App, ctx: &Context, args: &[String]) {
/// println!("Hello, {}", ctx.get_value("name").unwrap_or_else(|| "unnamed".into()));
/// println!("Hello, {}", ctx.get_string("name").unwrap_or_else(|| "unnamed".into()));
/// }
/// ```
pub fn run(&mut self) -> Result<()> {
@ -178,7 +186,7 @@ impl App {
/// This is the core logic for parsing arguments and executing handlers. It is ran but the App::run
/// and App::run_with functions.
fn run_command(app: &App, cmd: &Command, args: &[String], opts: &mut Vec<ActiveOpt>) -> Result<()> {
fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) -> Result<()> {
// Get an iterator for the incomming arguments
let mut args = args.iter();
// We will keep track of any arguments that arent consumed by the current command.
@ -192,11 +200,11 @@ fn run_command(app: &App, cmd: &Command, args: &[String], opts: &mut Vec<ActiveO
if let Some(opt) = cmd.opts.iter().find(|o| &o.long == &arg[2..]) {
match opt.kind {
OptKind::Flag => {
opts.push(ActiveOpt::new(opt.clone(), vec!["".into()]));
ctx.set_flag(opt);
}
OptKind::String => {
if let Some(value) = args.next() {
opts.push(ActiveOpt::new(opt.clone(), vec![value.clone()]));
ctx.set_opt(opt, value.clone());
} else {
return Err(OptError::InvalidOpt(opt.name.clone()));
}
@ -212,11 +220,11 @@ fn run_command(app: &App, cmd: &Command, args: &[String], opts: &mut Vec<ActiveO
if let Some(opt) = cmd.opts.iter().find(|o| &o.short == &arg[1..]) {
match opt.kind {
OptKind::Flag => {
opts.push(ActiveOpt::new(opt.clone(), vec!["".into()]));
ctx.set_flag(opt);
}
OptKind::String => {
if let Some(value) = args.next() {
opts.push(ActiveOpt::new(opt.clone(), vec![value.clone()]));
ctx.set_opt(opt, value.clone());
} else {
return Err(OptError::InvalidOpt(opt.name.clone()));
}
@ -237,7 +245,7 @@ fn run_command(app: &App, cmd: &Command, args: &[String], opts: &mut Vec<ActiveO
.find(|cmd| ignored.iter().any(|a| *a == cmd.name))
{
ignored.retain(|a| *a != cmd.name);
return run_command(app, cmd, &ignored, opts);
return run_command(app, cmd, &ignored, ctx);
}
// Automatic command help display
@ -253,16 +261,34 @@ fn run_command(app: &App, cmd: &Command, args: &[String], opts: &mut Vec<ActiveO
// Execute the command handler
if let Some(handler) = cmd.handler {
let mut ctx = Context::new(cmd.clone(), opts.clone());
ctx.cmd = cmd.clone();
if let Some(prefix) = app.env_prefix {
ctx.env_prefix = Some(prefix.to_string());
}
handler(app, &ctx, &ignored);
} else {
crate::vox::print(app.application_header());
print_command_help(cmd, &vec![])
}
Ok(())
}
#[cfg(not(feature = "config"))]
impl App {
pub fn config_filename(&mut self, _filename: &'static str) -> Self {
panic!("Not compiled with configuration capabilities. Add a config parser as a feature");
}
}
#[cfg(feature = "config")]
impl App {
pub fn config_filename(mut self, filename: &'static str) -> Self {
self.config_filename = Some(filename);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -270,14 +296,13 @@ mod tests {
#[test]
fn test_long_string() {
let args: Vec<String> = vec!["--user".into(), "joe".into()];
let app = App::new()
App::new()
.opt(Opt::scalar("user").short("u").long("user"))
.handler(|_, ctx, _| {
assert_eq!(ctx.get_value("user"), Some("joe".into()));
});
let res = app.run_with(args);
assert!(res.is_ok());
assert_eq!(ctx.get_string("user"), Some("joe".into()));
})
.run_with(args)
.unwrap();
}
#[test]
@ -289,18 +314,18 @@ mod tests {
"c.json".into(),
"thing".into(),
];
let app = App::new()
App::new()
.opt(Opt::scalar("user").short("u").long("user"))
.command(
Command::new("thing")
.opt(Opt::scalar("config").short("c").long("config"))
.handler(|_, ctx, _| {
assert_eq!(ctx.get_value("user"), Some("joe".into()));
assert_eq!(ctx.get_value("config"), Some("c.json".into()));
assert_eq!(ctx.get_string("user"), Some("joe".into()));
assert_eq!(ctx.get_string("config"), Some("c.json".into()));
}),
);
let res = app.run_with(args);
assert!(res.is_ok());
)
.run_with(args)
.unwrap();
}
fn function_handler(_app: &App, _ctx: &Context, _args: &[String]) {

View File

@ -1,32 +1,168 @@
use crate::{
opt::{ActiveOpt, OptKind},
Command,
};
use std::env;
use crate::{Command, Opt};
use std::{collections::BTreeMap, env};
#[derive(Clone, Debug)]
pub struct Map(BTreeMap<String, ContextValue>);
impl Map {
pub fn new() -> Self {
Map(BTreeMap::new())
}
pub fn set(&mut self, name: String, v: ContextValue) {
self.0.insert(name, v);
}
pub fn get(&self, name: &str) -> Option<&ContextValue> {
self.0.get(name)
}
}
#[derive(Clone, Debug)]
pub struct Array(Vec<ContextValue>);
#[derive(Clone, Debug)]
pub enum ContextValue {
String(String),
Integer(i64),
Float(f64),
Bool(bool),
Map(Map),
Array(Array),
}
#[derive(Clone, Copy, Debug)]
pub enum ValueKind {
String,
Integer,
Float,
Bool,
Map,
Array,
}
impl ContextValue {
pub fn string(&self) -> Option<String> {
if let Self::String(v) = self {
Some(v.clone())
} else {
None
}
}
pub fn bool(&self) -> Option<bool> {
if let Self::Bool(v) = self {
Some(*v)
} else {
None
}
}
pub fn integer(&self) -> Option<i64> {
if let Self::Integer(v) = self {
Some(*v)
} else {
None
}
}
pub fn float(&self) -> Option<f64> {
if let Self::Float(v) = self {
Some(*v)
} else {
None
}
}
pub fn map(&self) -> Map {
if let Self::Map(v) = self {
v.clone()
} else {
Map::new()
}
}
pub fn array(&self) -> Array {
if let Self::Array(v) = self {
v.clone()
} else {
Array(vec![])
}
}
pub fn kind(&self) -> ValueKind {
match self {
Self::String(_) => ValueKind::String,
Self::Integer(_) => ValueKind::Integer,
Self::Float(_) => ValueKind::Float,
Self::Bool(_) => ValueKind::Bool,
Self::Map(_) => ValueKind::Map,
Self::Array(_) => ValueKind::Array,
}
}
pub fn parse_string(value: String, kind: ValueKind) -> Option<Self> {
match kind {
ValueKind::String => Some(Self::String(value)),
ValueKind::Integer => value.parse::<i64>().ok().map(|v| Self::Integer(v)),
ValueKind::Float => value.parse::<f64>().ok().map(|v| Self::Float(v)),
ValueKind::Bool => value.parse::<bool>().ok().map(|v| Self::Bool(v)),
ValueKind::Array => None,
ValueKind::Map => None,
}
}
}
impl From<String> for ContextValue {
fn from(fr: String) -> Self {
ContextValue::String(fr)
}
}
impl From<i64> for ContextValue {
fn from(fr: i64) -> Self {
ContextValue::Integer(fr)
}
}
impl From<f64> for ContextValue {
fn from(fr: f64) -> Self {
ContextValue::Float(fr)
}
}
impl From<bool> for ContextValue {
fn from(fr: bool) -> Self {
ContextValue::Bool(fr)
}
}
pub struct Context {
opts: Vec<ActiveOpt>,
cmd: Command,
pub(crate) cmd: Command,
pub(crate) config_data: Map,
pub(crate) env_prefix: Option<String>,
pub(crate) env_enabled: bool,
}
impl Context {
pub(crate) fn new(cmd: Command, opts: Vec<ActiveOpt>) -> Self {
pub(crate) fn new(cmd: Command) -> Self {
Self {
opts,
cmd,
env_prefix: None,
env_enabled: true,
config_data: Map::new(),
}
}
pub(crate) fn set_opt<T: Into<ContextValue>>(&mut self, opt: &Opt, value: T) {
self.config_data.set(opt.name.clone(), value.into());
}
pub(crate) fn set_flag(&mut self, opt: &Opt) {
self.config_data.set(opt.name.clone(), true.into());
}
/// Checks for the existance of a flag
pub fn flag(&self, name: &str) -> bool {
self.opts
.iter()
.any(|o| o.definition.name == name && matches!(o.definition.kind, OptKind::Flag))
|| self.get_env_value(name).is_some()
self.config_data.get(name).is_some() || self.get_env_value(name).is_some()
}
fn get_env_value(&self, name: &str) -> Option<String> {
@ -42,24 +178,157 @@ impl Context {
}
/// Returns the value of an option if one exists
pub fn get_value(&self, name: &str) -> Option<String> {
self.opts
.iter()
.find_map(|o| {
if o.definition.name == name {
Some(o.raw_value.first().cloned())
pub fn get_value(&self, name: &str) -> Option<ContextValue> {
self.config_data
.get(name)
.cloned()
.or(self.get_env_value(name).map(|v| ContextValue::String(v)))
}
/// Returns a string for the field if it is a string
pub fn get_string(&self, name: &str) -> Option<String> {
match self.get_value(name) {
Some(ContextValue::String(v)) => Some(v),
Some(ContextValue::Integer(v)) => Some(v.to_string()),
Some(ContextValue::Float(v)) => Some(v.to_string()),
Some(ContextValue::Bool(v)) => Some(v.to_string()),
_ => None,
}
}
/// Returns a i64 for the field if it is an integer
pub fn get_int(&self, name: &str) -> Option<i64> {
match self.get_value(name) {
Some(ContextValue::String(v)) => v.parse().ok(),
Some(ContextValue::Integer(v)) => Some(v),
Some(ContextValue::Float(v)) => Some(v as i64),
Some(ContextValue::Bool(true)) => Some(1),
Some(ContextValue::Bool(false)) => Some(0),
_ => None,
}
}
/// Returns a f64 for the field if it is a float
pub fn get_float(&self, name: &str) -> Option<f64> {
match self.get_value(name) {
Some(ContextValue::String(v)) => v.parse().ok(),
Some(ContextValue::Integer(v)) => Some(v as f64),
Some(ContextValue::Float(v)) => Some(v),
Some(ContextValue::Bool(true)) => Some(1_f64),
Some(ContextValue::Bool(false)) => Some(0_f64),
_ => None,
}
}
/// Returns a f64 for the field if it is a float
pub fn get_bool(&self, name: &str) -> Option<bool> {
match self.get_value(name) {
Some(ContextValue::String(v)) => v.parse().ok(),
Some(ContextValue::Integer(0)) => Some(false),
Some(ContextValue::Integer(_)) => Some(true),
Some(ContextValue::Float(v)) => {
if v == 0_f64 {
Some(false)
} else {
None
Some(true)
}
})
.flatten()
.or(self.get_env_value(name))
}
Some(ContextValue::Bool(v)) => Some(v),
_ => None,
}
}
/// Can be used to display the automatic help message for the current command.
pub fn display_help(&self) {
crate::command::print_command_help(&self.cmd, &vec![])
}
pub(crate) fn load_config_file(&mut self, filename: &str) {
if std::fs::metadata(filename)
.ok()
.map(|f| f.is_file())
.unwrap_or(false)
{
let contents = std::fs::read_to_string(filename).unwrap();
self.load_config_str(&contents);
}
}
}
#[cfg(not(feature = "config"))]
mod noconfig {
use super::Context;
impl Context {
pub(crate) fn load_config_str(&mut self, _filename: &str) {}
}
}
#[cfg(feature = "config_toml")]
mod toml {
use super::{Array, Context, ContextValue, Map};
impl From<toml::Value> for ContextValue {
fn from(fr: toml::Value) -> Self {
match fr {
toml::Value::String(v) => ContextValue::String(v),
toml::Value::Integer(v) => ContextValue::Integer(v),
toml::Value::Float(v) => ContextValue::Float(v),
toml::Value::Boolean(v) => ContextValue::Bool(v),
toml::Value::Array(v) => ContextValue::Array(Array(
v.iter().map(|v| ContextValue::from(v.clone())).collect(),
)),
toml::Value::Table(v) => ContextValue::Map(Map(v
.iter()
.map(|(k, v)| (k.clone(), ContextValue::from(v.clone())))
.collect())),
toml::Value::Datetime(v) => ContextValue::String(v.to_string()),
}
}
}
impl Context {
pub(crate) fn load_config_str(&mut self, data: &str) {
self.config_data = ContextValue::from(toml::from_str::<toml::Value>(data).unwrap())
.map()
.clone()
}
}
}
#[cfg(feature = "config_json")]
mod json {
use super::{Array, Context, ContextValue, Map};
use serde_json::Value;
impl From<Value> for ContextValue {
fn from(fr: Value) -> Self {
match fr {
Value::String(v) => ContextValue::String(v),
Value::Number(v) if v.is_i64() => ContextValue::Integer(v.as_i64().unwrap_or(0)),
Value::Number(v) if v.is_f64() => ContextValue::Float(v.as_f64().unwrap_or(0_f64)),
Value::Number(v) => ContextValue::Integer(v.as_i64().unwrap_or(0)),
Value::Array(v) => ContextValue::Array(Array(
v.iter().map(|v| ContextValue::from(v.clone())).collect(),
)),
Value::Bool(v) => ContextValue::Bool(v),
Value::Null => ContextValue::String("null".to_string()),
Value::Object(v) => ContextValue::Map(Map(v
.iter()
.map(|(k, v)| (k.clone(), ContextValue::from(v.clone())))
.collect())),
}
}
}
impl Context {
pub(crate) fn load_config_str(&mut self, data: &str) {
self.config_data =
ContextValue::from(serde_json::from_str::<Value>(data).unwrap_or_default())
.map()
.clone()
}
}
}
#[cfg(test)]
@ -80,7 +349,7 @@ mod tests {
App::new()
.opt(Opt::scalar("thing").short("-t").long("--t"))
.handler(|_, ctx, _| {
assert_eq!(ctx.get_value("thing"), Some("one".into()));
assert_eq!(ctx.get_string("thing"), Some("one".into()));
})
.run_with(vec![])
.unwrap();
@ -89,9 +358,47 @@ mod tests {
App::new()
.opt(Opt::scalar("thing").short("t").long("thing"))
.handler(|_, ctx, _| {
assert_eq!(ctx.get_value("thing"), Some("1".into()));
assert_eq!(ctx.get_string("thing"), Some("1".into()));
})
.run_with(vec!["-t".into(), "1".into()])
.unwrap();
}
#[cfg(feature = "config_toml")]
#[test]
fn config_load_toml() {
use super::{Command, Context};
let cmd = Command::new("xxx");
let mut ctx = Context::new(cmd);
ctx.load_config_str(
r#"
config_thing = "good"
num = 15
[nested]
something = 14
"#,
);
assert_eq!(ctx.get_string("config_thing"), Some("good".into()));
}
#[cfg(feature = "config_json")]
#[test]
fn config_load_toml() {
use super::{Command, Context};
let cmd = Command::new("xxx");
let mut ctx = Context::new(cmd);
ctx.load_config_str(
r#"
{
"config_thing": "good",
"num": 15,
"nested": {
"something": 14
}
}
"#,
);
assert_eq!(ctx.get_string("config_thing"), Some("good".into()));
}
}

View File

@ -106,18 +106,3 @@ pub(crate) enum OptKind {
Flag,
String,
}
#[derive(Clone, Debug)]
pub(crate) struct ActiveOpt {
pub definition: Opt,
pub raw_value: Vec<String>,
}
impl ActiveOpt {
pub fn new(definition: Opt, raw_value: Vec<String>) -> Self {
ActiveOpt {
definition,
raw_value,
}
}
}