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, - } - } -}