diff --git a/.gitignore b/.gitignore index 96ef6c0..b471067 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +.idea diff --git a/Cargo.toml b/Cargo.toml index d9e6b27..12e6da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,26 @@ [package] -name = "appbase" +name = "clappconfig" version = "0.1.0" authors = ["Ondřej Hruška "] edition = "2018" -publish = false - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +license = "MIT" +description = "Clap-based app config boilerplate: set up logging, load config, parse args" +repository = "https://git.ondrovo.com/packages/cli-app-base" +readme = "README.md" +keywords = ["clap", "cli", "logging", "boilerplate"] +categories = [ + "command-line-interface" +] [dependencies] log = "0.4" -env_logger = "0.7.1" +env_logger = "0.7" failure = "0.1" -serde = "1.0.106" -serde_json = "1.0.51" -clap = "2.33.0" -json5 = "0.2.7" +serde = "1.0" +serde_json = "1.0" +clap = "2.33" +json5 = "0.2" + +[dev-dependencies] +smart-default = "0.6" +serde_derive = "1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f238dbd --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# clappconfig - CLI app config boilerplate + +```none +Clap + App + Config +----------- +clappconfig +``` + +This crate provides a simple CLI app boilerplate that takes care of +the most repetitive init tasks: + +- Load JSON5 config from a custom or default path (or use `Default::default()`) +- Set up logging, with CLI and config-based overrides +- Optionally print the loaded or default config +- Show help / version on demand, courtesy of `clap` +- Optional start-up banner print + +Supports custom `clap` arguments as well. + +-> The repository is open to improvement PRs. + +## Logging + +- uses `env_logger` by default +- level can be set in the config file +- log level can be overridden by the `--log` CLI flag +- log level can be increased by repeated use of `-v` or `--verbose` + +## Example + +See the examples directory. + +The example called "rotn" implements rot13 as a command-line tool. + +```none +$ cargo run --example complete -- -h + +Rot-N 0.1.0 by Ondřej Hruška + +USAGE: + rotn [FLAGS] [OPTIONS] --input + +FLAGS: + --default-config Print the default config JSON for reference (or to be piped to a file) + --dump-config Print the loaded config struct + -h, --help Prints help information + -V, --version Prints version information + -v, --verbose Increase logging verbosity (repeat to increase) + +OPTIONS: + -c, --config Sets a custom config file (JSON5) + -i, --input Input file + --log Set logging verbosity (error,warning,info,debug,trace) + -n, --shift Positive or negative shift, default 13 +``` + +To get rot13 of this README, run `cargo run --example rotn -- -iREADME.md`. diff --git a/examples/foo.json b/examples/foo.json new file mode 100644 index 0000000..4fb745c --- /dev/null +++ b/examples/foo.json @@ -0,0 +1,5 @@ +// This is a JSON5 file with config for the "minimal.rs" example +{ + // a foo + "foo": 1234, +} diff --git a/examples/minimal.rs b/examples/minimal.rs new file mode 100644 index 0000000..5b7e832 --- /dev/null +++ b/examples/minimal.rs @@ -0,0 +1,41 @@ +#[macro_use] +extern crate serde_derive; +#[macro_use] +extern crate log; + +use clap::ArgMatches; +use clappconfig::AppConfig; +use failure::Fallible; + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(default)] +struct Config { + foo: u32, + bar: u32, +} + +impl AppConfig for Config { + type Init = Config; + + fn logging(&self) -> &str { + "info" + } + + fn configure<'a>(self, _clap: &ArgMatches<'a>) -> Fallible { + Ok(self) + } +} + +fn main() -> Fallible<()> { + let cfg = Config::init("Foo", "examples/foo.json", None)?; + + trace!("Trace message"); + debug!("Debug message"); + info!("Info message"); + warn!("Warn message"); + error!("Error message"); + + println!("Welcome to foo, config:\n{:#?}", cfg); + + Ok(()) +} diff --git a/examples/rotn.rs b/examples/rotn.rs new file mode 100644 index 0000000..96e6ed3 --- /dev/null +++ b/examples/rotn.rs @@ -0,0 +1,159 @@ +#[macro_use] +extern crate serde_derive; +#[macro_use] +extern crate failure; +#[macro_use] +extern crate log; + +use clap::ArgMatches; +use clappconfig::AppConfig; +use failure::Fallible; +use smart_default::SmartDefault; +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Read; +use std::path::PathBuf; +use std::str::FromStr; + +#[derive(Serialize, Deserialize, Debug, SmartDefault)] +#[serde(deny_unknown_fields)] +#[serde(default)] +struct Config { + #[default = "info"] + logging: String, + log_modules: HashMap, + + #[default = 13] + offset: i32, +} + +struct Main { + config: Config, + input_file: PathBuf, +} + +impl AppConfig for Config { + type Init = Main; + + fn logging(&self) -> &str { + &self.logging + } + + fn logging_suppress_mods(&self) -> Option> { + Some(vec![ + "tokio_reactor", + "tokio_io", + "hyper", + "reqwest", + "mio", + "want", + ]) + } + + /// Print startup banner. + /// May be overridden to customize or disable it. + fn print_banner(_name: &str, _version: &str) { + // Disable + } + + /// Log messages printed before logging is set up + /// May be overridden to customize or disable it. + fn pre_log_println(_message: String) { + // Disable + } + + /// Get log module levels to use (take priority over the main log level) + fn logging_mod_levels(&self) -> Option<&HashMap> { + Some(&self.log_modules) + } + + fn add_args<'a: 'b, 'b>(clap: clap::App<'a, 'b>) -> clap::App<'a, 'b> { + // Default impl + clap.arg( + clap::Arg::with_name("input") + .short("i") + .long("input") + .value_name("FILE") + .required_unless("default-config") + .help("Input file") + .takes_value(true), + ) + .arg( + clap::Arg::with_name("offset") + .short("n") + .long("shift") + .value_name("N") + .help("Positive or negative shift, default 13") + .takes_value(true), + ) + } + + fn configure<'a>(mut self, clap: &ArgMatches<'a>) -> Fallible { + let input = clap.value_of("input").unwrap().to_string(); + + debug!("Input: {}", input); + + if input.is_empty() { + bail!("Input is required!"); + } + + let pb = PathBuf::from_str(&input)?.canonicalize()?; + + debug!("Input path: {}", pb.display()); + + if !pb.is_file() { + return Err(format_err!("Input is not a file!")); + } + + // Set offset by arg + if let Some(v) = clap.value_of("offset") { + self.offset = v.parse()?; + } + + debug!("Rot raw: {}", self.offset); + + self.offset = i32::rem_euclid(self.offset, 26); + + debug!("Rot normalized: {}", self.offset); + + Ok(Main { + config: self, + input_file: pb, + }) + } +} + +fn rot_n(string: String, rot: u8) -> String { + assert!(rot < 26); + const LOWER: &[u8] = b"abcdefghihjlmnopqrstuvwxyz"; + const UPPER: &[u8] = b"ABCDEFGHIHJLMNOPQRSTUVWXYZ"; + + string + .chars() + .map(|c| match c { + 'A'..='Z' => UPPER[((c as u8 - 'A' as u8 + rot) % 26) as usize] as char, + 'a'..='z' => LOWER[((c as u8 - 'a' as u8 + rot) % 26) as usize] as char, + _ => c, + }) + .collect() +} + +fn main() -> Fallible<()> { + // Using custom version (default is CARGO_PKG_VERSION) + let version = format!( + "{} by {}", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_AUTHORS") + ); + + let main = Config::init("Rot-N", "rotn_config.json", Some(&version))?; + + let mut f = OpenOptions::new().read(true).open(main.input_file)?; + + let mut buf = String::new(); + f.read_to_string(&mut buf)?; + + println!("{}", rot_n(buf, main.config.offset as u8)); + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 675198c..4ec0e3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,19 @@ pub trait AppConfig: Sized + Serialize + DeserializeOwned + Debug + Default { type Init; /// Get log level - fn logging(&self) -> &String; + fn logging(&self) -> &str; + + /// Print startup banner. + /// May be overridden to customize or disable it. + fn print_banner(name: &str, version: &str) { + println!("{} {}\nRun with -h for help", name, version); + } + + /// Log messages printed before logging is set up + /// May be overridden to customize or disable it. + fn pre_log_println(message: String) { + println!("{}", message); + } /// Get names of library modules to suppress from log output (limit to warn or error) fn logging_suppress_mods(&self) -> Option> { @@ -103,25 +115,26 @@ pub trait AppConfig: Sized + Serialize + DeserializeOwned + Debug + Default { /* Load config */ let cfg_file = argv.value_of("config").unwrap_or(cfg_file_name); - println!("{} {}\nRun with -h for help", name, version); + Self::print_banner(name, version); let filepath = PathBuf::from_str(cfg_file)?; let config: Self = if filepath.is_file() { - println!("Load config from \"{}\"", cfg_file); + let path = filepath.canonicalize()?; + Self::pre_log_println(format!("Loading config from: {}", path.display())); - let buf = read_file(filepath)?; + let buf = read_file(path)?; json5::from_str(&buf)? } else { - println!( + Self::pre_log_println(format!( "Config file \"{}\" does not exist, using defaults.", cfg_file - ); + )); Self::default() }; - let mut level = config.logging().as_str(); + let mut level = config.logging(); if !LOG_LEVELS.contains(&level) { bail!("Invalid default log level: {}", level); @@ -163,8 +176,8 @@ pub trait AppConfig: Sized + Serialize + DeserializeOwned + Debug + Default { println!(); builder.format_timestamp_millis(); - - let mut per_mod = vec![]; + + let mut per_mod = vec![]; if let Some(mod_levels) = config.logging_mod_levels() { for (module, lvl) in mod_levels { per_mod.push((module, log::LevelFilter::from_str(lvl)?)); @@ -187,11 +200,11 @@ pub trait AppConfig: Sized + Serialize + DeserializeOwned + Debug + Default { continue 'sup; } } - + builder.filter_module(lib, sup_lvl); } } - + for (module, lvl) in per_mod.into_iter() { builder.filter_module(module, lvl); }