use std::{fs, num::NonZeroUsize, path::Path}; use chrono::{DateTime, Local}; use clap::{load_yaml, App, ArgMatches}; use flexi_logger::LevelFilter as LogLevel; use itertools::Itertools; type Error = crate::errors::CliError; const DEFAULT_MAX_CONN: usize = 8; #[derive(derive_builder::Builder, Debug)] pub struct AppConfig { /// Article urls pub urls: Vec, pub max_conn: usize, /// Path to file of multiple articles into a single article pub merged: Option, // TODO: Change type to Path pub output_directory: Option, pub log_level: LogLevel, pub can_disable_progress_bar: bool, pub start_time: DateTime, pub is_logging_to_file: bool, pub inline_toc: bool, pub css_config: CSSConfig, pub export_type: ExportType, pub is_inlining_images: bool, } impl AppConfig { pub fn init_with_cli() -> Result { let yaml_config = load_yaml!("cli_config.yml"); let app = App::from_yaml(yaml_config).version(clap::crate_version!()); Self::try_from(app.get_matches()) } fn init_merge_file(self) -> Result { self.merged .as_deref() .map(fs::File::create) .transpose() .err() .map(|err| Err(Error::InvalidOutputPath(err.to_string()))) .unwrap_or(Ok(self)) } fn init_logger(self) -> Result { use crate::logs; logs::init_logger(self.log_level, &self.start_time, self.is_logging_to_file) .map(|_| self) .map_err(Error::LogError) } } use std::convert::TryFrom; impl<'a> TryFrom> for AppConfig { type Error = Error; fn try_from(arg_matches: ArgMatches<'a>) -> Result { AppConfigBuilder::default() .urls({ let url_filter = |url: &str| { let url = url.trim(); if !url.is_empty() { Some(url.to_owned()) } else { None } }; let direct_urls = arg_matches .values_of("urls") .and_then(|urls| urls.map(url_filter).collect::>>()) .unwrap_or(Vec::new()); let file_urls = arg_matches .value_of("file") .map(fs::read_to_string) .transpose()? .and_then(|content| content.lines().map(url_filter).collect::>>()) .unwrap_or(Vec::new()); let urls = [direct_urls, file_urls] .concat() .into_iter() .unique() .collect_vec(); if !urls.is_empty() { Ok(urls) } else { Err(Error::NoUrls) } }?) .max_conn(match arg_matches.value_of("max-conn") { Some(max_conn) => max_conn.parse::()?.get(), None => DEFAULT_MAX_CONN, }) .merged(arg_matches.value_of("output-name").map(|name| { let file_ext = format!(".{}", arg_matches.value_of("export").unwrap_or("epub")); if name.ends_with(&file_ext) { name.to_owned() } else { name.to_string() + &file_ext } })) .can_disable_progress_bar( arg_matches.is_present("verbosity") && !arg_matches.is_present("log-to-file"), ) .log_level(match arg_matches.occurrences_of("verbosity") { 0 => { if !arg_matches.is_present("log-to-file") { LogLevel::Off } else { LogLevel::Debug } } 1 => LogLevel::Error, 2 => LogLevel::Warn, 3 => LogLevel::Info, 4..=u64::MAX => LogLevel::Debug, }) .is_logging_to_file(arg_matches.is_present("log-to-file")) .inline_toc( (if arg_matches.is_present("inline-toc") { if arg_matches.value_of("export") == Some("epub") { Ok(true) } else { Err(Error::WrongExportInliningToC) } } else { Ok(false) })?, ) .output_directory( arg_matches .value_of("output-directory") .map(|output_directory| { let path = Path::new(output_directory); if !path.exists() { // TODO: Create the directory Err(Error::OutputDirectoryNotExists) } else if !path.is_dir() { Err(Error::WrongOutputDirectory) } else { Ok(output_directory.to_owned()) } }) .transpose()?, ) .start_time(Local::now()) .css_config( match ( arg_matches.is_present("no-css"), arg_matches.is_present("no-header-css"), ) { (true, _) => CSSConfig::None, (_, true) => CSSConfig::NoHeaders, _ => CSSConfig::All, }, ) .export_type({ let export_type = arg_matches.value_of("export").unwrap_or("epub"); if export_type == "html" { ExportType::HTML } else { ExportType::EPUB } }) .is_inlining_images( (if arg_matches.is_present("inline-images") { if arg_matches.value_of("export") == Some("html") { Ok(true) } else { Err(Error::WrongExportInliningImages) } } else { Ok(false) })?, ) .try_init() } } impl AppConfigBuilder { pub fn try_init(&self) -> Result { self.build() .map_err(Error::AppBuildError)? .init_logger()? .init_merge_file() } } #[derive(Clone, Debug)] pub enum CSSConfig { All, NoHeaders, None, } #[derive(Clone, Debug)] pub enum ExportType { HTML, EPUB, } #[cfg(test)] mod test { use super::*; #[test] fn test_clap_config_errors() { let yaml_config = load_yaml!("cli_config.yml"); let app = App::from_yaml(yaml_config); // It returns Ok when only a url is passed let result = app .clone() .get_matches_from_safe(vec!["paperoni", "http://example.org"]); assert!(result.is_ok()); // It returns an error when no args are passed let result = app.clone().get_matches_from_safe(vec!["paperoni"]); assert!(result.is_err()); assert_eq!( clap::ErrorKind::MissingArgumentOrSubcommand, result.unwrap_err().kind ); // It returns an error when both output-dir and merge are used let result = app.clone().get_matches_from_safe(vec![ "paperoni", "http://example.org", "--merge", "foo", "--output-dir", "~", ]); assert!(result.is_err()); assert_eq!(clap::ErrorKind::ArgumentConflict, result.unwrap_err().kind); // It returns an error when both no-css and no-header-css are used let result = app.clone().get_matches_from_safe(vec![ "paperoni", "http://example.org", "--no-css", "--no-header-css", ]); assert!(result.is_err()); assert_eq!(clap::ErrorKind::ArgumentConflict, result.unwrap_err().kind); // It returns an error when inline-toc is used without merge let result = app.clone().get_matches_from_safe(vec![ "paperoni", "http://example.org", "--inline-toc", ]); assert!(result.is_err()); assert_eq!( clap::ErrorKind::MissingRequiredArgument, result.unwrap_err().kind ); // It returns an error when inline-images is used without export let result = app.clone().get_matches_from_safe(vec![ "paperoni", "http://example.org", "--inline-images", ]); assert!(result.is_err()); assert_eq!( clap::ErrorKind::MissingRequiredArgument, result.unwrap_err().kind ); // It returns an error when export is given an invalid value let result = app.clone().get_matches_from_safe(vec![ "paperoni", "http://example.org", "--export", "pdf", ]); assert!(result.is_err()); assert_eq!(clap::ErrorKind::InvalidValue, result.unwrap_err().kind); // It returns an error when a max-conn is given a negative number. let result = app.clone().get_matches_from_safe(vec![ "paperoni", "http://example.org", "--max-conn", "-1", ]); assert!(result.is_err()); // The cli is configured not to accept negative numbers so passing "-1" would have it be read as a flag called 1 assert_eq!(clap::ErrorKind::UnknownArgument, result.unwrap_err().kind); } #[test] fn test_init_with_cli() { let yaml_config = load_yaml!("cli_config.yml"); let app = App::from_yaml(yaml_config); // It returns an error when the urls passed are whitespace let matches = app.clone().get_matches_from(vec!["paperoni", ""]); let app_config = AppConfig::try_from(matches); assert!(app_config.is_err()); assert_eq!(Error::NoUrls, app_config.unwrap_err()); // It returns an error when inline-toc is used when exporting to HTML let matches = app.clone().get_matches_from(vec![ "paperoni", "http://example.org", "--merge", "foo", "--export", "html", "--inline-toc", ]); let app_config = AppConfig::try_from(matches); assert!(app_config.is_err()); assert_eq!(Error::WrongExportInliningToC, app_config.unwrap_err()); // It returns an Ok when inline-toc is used when exporting to epub let matches = app.clone().get_matches_from(vec![ "paperoni", "http://example.org", "--merge", "foo", "--export", "epub", "--inline-toc", ]); assert!(AppConfig::try_from(matches).is_ok()); // It returns an error when inline-images is used when exporting to epub } }