From 7c64993a072bfbebed8b92f5bc378d77ec582a11 Mon Sep 17 00:00:00 2001 From: Joe Bellus Date: Mon, 21 Jun 2021 01:24:32 +0000 Subject: [PATCH] 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. --- .gitlab-ci.yml | 4 +- Cargo.lock | 15 ++ Cargo.toml | 9 ++ benches/arg_parsing.rs | 7 +- examples/config.rs | 23 +++ examples/config.toml | 1 + examples/fib.rs | 2 +- src/app.rs | 75 ++++++--- src/context.rs | 357 ++++++++++++++++++++++++++++++++++++++--- src/opt.rs | 15 -- 10 files changed, 436 insertions(+), 72 deletions(-) create mode 100644 examples/config.rs create mode 100644 examples/config.toml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2d7efc2..6b2cb16 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 95d9a46..4b0ed33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b16c3b9..64abb1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 + diff --git a/benches/arg_parsing.rs b/benches/arg_parsing.rs index c700c5c..c28f364 100644 --- a/benches/arg_parsing.rs +++ b/benches/arg_parsing.rs @@ -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(|| { diff --git a/examples/config.rs b/examples/config.rs new file mode 100644 index 0000000..b8070f5 --- /dev/null +++ b/examples/config.rs @@ -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()) + ); +} diff --git a/examples/config.toml b/examples/config.toml new file mode 100644 index 0000000..ebf1537 --- /dev/null +++ b/examples/config.toml @@ -0,0 +1 @@ +name = "Alice Alisson" diff --git a/examples/fib.rs b/examples/fib.rs index 25dacd2..f0237ff 100644 --- a/examples/fib.rs +++ b/examples/fib.rs @@ -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), diff --git a/src/app.rs b/src/app.rs index b541c2a..a65990e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) -> Result<()> { - run_command(self, &self.root, &args, &mut vec![]) + pub fn run_with(&mut self, args: Vec) -> 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) -> 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 { - 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 { - 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 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 = 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]) { diff --git a/src/context.rs b/src/context.rs index bb69c40..9b4ffd6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -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); + +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); + +#[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 { + if let Self::String(v) = self { + Some(v.clone()) + } else { + None + } + } + + pub fn bool(&self) -> Option { + if let Self::Bool(v) = self { + Some(*v) + } else { + None + } + } + + pub fn integer(&self) -> Option { + if let Self::Integer(v) = self { + Some(*v) + } else { + None + } + } + + pub fn float(&self) -> Option { + 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 { + match kind { + ValueKind::String => Some(Self::String(value)), + ValueKind::Integer => value.parse::().ok().map(|v| Self::Integer(v)), + ValueKind::Float => value.parse::().ok().map(|v| Self::Float(v)), + ValueKind::Bool => value.parse::().ok().map(|v| Self::Bool(v)), + ValueKind::Array => None, + ValueKind::Map => None, + } + } +} + +impl From for ContextValue { + fn from(fr: String) -> Self { + ContextValue::String(fr) + } +} + +impl From for ContextValue { + fn from(fr: i64) -> Self { + ContextValue::Integer(fr) + } +} + +impl From for ContextValue { + fn from(fr: f64) -> Self { + ContextValue::Float(fr) + } +} + +impl From for ContextValue { + fn from(fr: bool) -> Self { + ContextValue::Bool(fr) + } +} pub struct Context { - opts: Vec, - cmd: Command, + pub(crate) cmd: Command, + pub(crate) config_data: Map, pub(crate) env_prefix: Option, pub(crate) env_enabled: bool, } impl Context { - pub(crate) fn new(cmd: Command, opts: Vec) -> 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>(&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 { @@ -42,24 +178,157 @@ impl Context { } /// Returns the value of an option if one exists - pub fn get_value(&self, name: &str) -> Option { - 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 { + 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 { + 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 { + 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 { + 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 { + 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 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::(data).unwrap()) + .map() + .clone() + } + } +} + +#[cfg(feature = "config_json")] +mod json { + use super::{Array, Context, ContextValue, Map}; + use serde_json::Value; + + impl From 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::(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())); + } } diff --git a/src/opt.rs b/src/opt.rs index 6b6bbe9..6a59416 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -106,18 +106,3 @@ pub(crate) enum OptKind { Flag, String, } - -#[derive(Clone, Debug)] -pub(crate) struct ActiveOpt { - pub definition: Opt, - pub raw_value: Vec, -} - -impl ActiveOpt { - pub fn new(definition: Opt, raw_value: Vec) -> Self { - ActiveOpt { - definition, - raw_value, - } - } -}