use clap::{ builder::{styling::AnsiColor as Ansi, Styles}, value_parser, Arg, ArgAction, ArgMatches, ColorChoice, Command, }; use crate::config::Config; use crate::error::Result; // TODO --add-site (in addition to defaults) // TODO set_api_key should probably just be a bool, since we have config pub struct Opts { pub list_sites: bool, pub print_config_path: bool, pub update_sites: bool, pub set_api_key: Option, pub query: Option, pub config: Config, } /// Get CLI opts and args, with defaults pulled from user configuration pub fn get_opts() -> Result { get_opts_with(Config::new, |a| a.get_matches()) } /// Get CLI opts, starting with defaults produced from `mk_config` and matching args with /// `get_matches`. fn get_opts_with(mk_config: F, get_matches: G) -> Result where F: FnOnce() -> Result, G: for<'a> FnOnce(Command) -> ArgMatches, { let config = mk_config()?; let limit = config.limit.to_string(); let sites = config.sites.join(";"); let engine = config.search_engine.to_string(); let clapp = Command::new("so") .color(ColorChoice::Always) .styles(STYLES) .version(clap::crate_version!()) .author(clap::crate_authors!()) .about(clap::crate_description!()) .arg( Arg::new("list-sites") .long("list-sites") .action(ArgAction::SetTrue) .help("Print available StackExchange sites"), ) .arg( Arg::new("update-sites") .long("update-sites") .action(ArgAction::SetTrue) .help("Update cache of StackExchange sites"), ) .arg( Arg::new("set-api-key") .long("set-api-key") .num_args(1) .value_name("key") .help("Set StackExchange API key"), ) .arg( Arg::new("print-config-path") .long("print-config-path") .help("Print path to config file") .action(ArgAction::SetTrue) .hide(true), ) .arg( Arg::new("site") .long("site") .short('s') .action(ArgAction::Append) .num_args(1) .default_value(&sites) .value_name("site-code") .help("StackExchange site to search"), ) .arg( Arg::new("limit") .long("limit") .short('l') .num_args(1) .default_value(&limit) .value_name("int") .value_parser(value_parser!(u16)) .help("Question limit"), ) .arg( Arg::new("lucky") .long("lucky") .action(ArgAction::SetTrue) .help("Print the top-voted answer of the most relevant question"), ) .arg( Arg::new("no-lucky") .long("no-lucky") .action(ArgAction::SetTrue) .help("Disable lucky") .conflicts_with("lucky") .hide(!config.lucky), ) .arg( Arg::new("query") .num_args(1..) .index(1) .required_unless_present_any([ "list-sites", "update-sites", "set-api-key", "print-config-path", ]), ) .arg( Arg::new("search-engine") .long("search-engine") .short('e') .num_args(1) .default_value(&engine) .value_name("engine") .value_parser(["duckduckgo", "google", "stackexchange"]) .help("Use specified search engine") .next_line_help(true), ); let matches = get_matches(clapp); let lucky = match (matches.get_flag("lucky"), matches.get_flag("no-lucky")) { (true, _) => true, (_, true) => false, _ => config.lucky, }; Ok(Opts { list_sites: matches.get_flag("list-sites"), print_config_path: matches.get_flag("print-config-path"), update_sites: matches.get_flag("update-sites"), set_api_key: matches.get_one("set-api-key").cloned(), query: matches .get_many::("query") .map(|words| words.map(|s| s.as_str()).collect::>().join(" ")), config: Config { // these unwraps are safe via clap default values & validators limit: *matches.get_one("limit").unwrap(), search_engine: serde_yaml::from_str( matches.get_one::("search-engine").unwrap(), )?, sites: matches .get_many::("site") .expect("at least one site is required!") .flat_map(|s| s.split(';')) .map(String::from) .collect(), api_key: matches.get_one("set-api-key").cloned().or(config.api_key), lucky, ..config }, }) } const STYLES: Styles = Styles::styled() .header(Ansi::Red.on_default().bold()) .usage(Ansi::Red.on_default().bold()) .literal(Ansi::Blue.on_default().bold()) .placeholder(Ansi::Green.on_default()); #[cfg(test)] mod tests { use super::*; use crate::config::SearchEngine; fn defaults() -> Config { Config { api_key: Some(String::from("my key")), limit: 64, lucky: false, sites: vec![ String::from("some"), String::from("sites"), String::from("yeah"), ], search_engine: SearchEngine::DuckDuckGo, copy_cmd: Some(String::from("wl-copy")), } } fn mk_config() -> Result { Ok(defaults()) } #[test] fn test_defaults() { let opts = get_opts_with(mk_config, |a| { a.get_matches_from(vec!["so", "how do I exit Vim"]) }); assert_eq!(opts.unwrap().config, defaults()); } #[test] fn test_overrides() { let opts = get_opts_with(mk_config, |a| { a.get_matches_from(vec!["so", "-s", "english", "how do I exit Vim"]) }); assert_eq!( opts.unwrap().config, Config { sites: vec![String::from("english")], ..defaults() } ); let opts = get_opts_with(mk_config, |a| { a.get_matches_from(vec!["so", "-l", "5", "--lucky", "how do I exit Vim"]) }); assert_eq!( opts.unwrap().config, Config { limit: 5, lucky: true, ..defaults() } ); } #[test] fn test_set_api_key() { let opts = get_opts_with(mk_config, |a| { a.get_matches_from(vec!["so", "--set-api-key", "new key"]) }) .unwrap(); // Uses key in new config assert_eq!( opts.config, Config { api_key: Some(String::from("new key")), ..defaults() } ); // Flags it in opts assert_eq!(opts.set_api_key, Some(String::from("new key"))); } #[test] #[should_panic] fn test_conflicts() { get_opts_with(mk_config, |a| { a.try_get_matches_from(vec!["so", "--lucky", "--no-lucky"]) .unwrap() }) .unwrap(); } }