diff --git a/Cargo.lock b/Cargo.lock index 1e66c7d..099bdf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,6 +483,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim 0.10.0", + "terminal_size 0.3.0", ] [[package]] @@ -2094,7 +2095,7 @@ dependencies = [ "shellexpand-utils 0.1.0", "tempfile", "termcolor", - "terminal_size", + "terminal_size 0.1.17", "tokio", "toml 0.7.8", "toml_edit 0.19.15", @@ -4104,6 +4105,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "terminal_size" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +dependencies = [ + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "thiserror" version = "1.0.50" diff --git a/Cargo.toml b/Cargo.toml index 2803579..21a4b20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,11 +59,11 @@ version = "0.2" version = "0.4.24" [dependencies.clap] -version = "4.0" -features = ["derive"] +version = "4.4" +features = ["derive", "wrap_help"] [dependencies.clap_complete] -version = "4.0" +version = "4.4" [dependencies.clap_mangen] version = "0.2" diff --git a/src/account/args.rs b/src/account/args.rs deleted file mode 100644 index da2264c..0000000 --- a/src/account/args.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! This module provides arguments related to the user account config. - -use anyhow::Result; -use clap::{Arg, ArgAction, ArgMatches, Command}; -use email::folder::sync::FolderSyncStrategy; -use log::info; -use std::collections::HashSet; - -use crate::{folder, ui::table}; - -const ARG_ACCOUNT: &str = "account"; -const ARG_DRY_RUN: &str = "dry-run"; -const ARG_RESET: &str = "reset"; -const CMD_ACCOUNT: &str = "account"; -const CMD_CONFIGURE: &str = "configure"; -const CMD_LIST: &str = "list"; -const CMD_SYNC: &str = "sync"; - -type DryRun = bool; -type Reset = bool; - -/// Represents the account commands. -#[derive(Debug, PartialEq, Eq)] -pub enum Cmd { - /// Represents the list accounts command. - List(table::args::MaxTableWidth), - /// Represents the sync account command. - Sync(Option, DryRun), - /// Configure the current selected account. - Configure(Reset), -} - -/// Represents the account command matcher. -pub fn matches(m: &ArgMatches) -> Result> { - let cmd = if let Some(m) = m.subcommand_matches(CMD_ACCOUNT) { - if let Some(m) = m.subcommand_matches(CMD_CONFIGURE) { - info!("configure account subcommand matched"); - let reset = parse_reset_flag(m); - Some(Cmd::Configure(reset)) - } else if let Some(m) = m.subcommand_matches(CMD_LIST) { - info!("list accounts subcommand matched"); - let max_table_width = table::args::parse_max_width(m); - Some(Cmd::List(max_table_width)) - } else if let Some(m) = m.subcommand_matches(CMD_SYNC) { - info!("sync account subcommand matched"); - let dry_run = parse_dry_run_arg(m); - let include = folder::args::parse_include_arg(m); - let exclude = folder::args::parse_exclude_arg(m); - let folders_strategy = if let Some(folder) = folder::args::parse_global_source_arg(m) { - Some(FolderSyncStrategy::Include(HashSet::from_iter([ - folder.to_owned() - ]))) - } else if !include.is_empty() { - Some(FolderSyncStrategy::Include(include.to_owned())) - } else if !exclude.is_empty() { - Some(FolderSyncStrategy::Exclude(exclude)) - } else if folder::args::parse_all_arg(m) { - Some(FolderSyncStrategy::All) - } else { - None - }; - Some(Cmd::Sync(folders_strategy, dry_run)) - } else { - None - } - } else { - None - }; - - Ok(cmd) -} - -/// Represents the account subcommand. -pub fn subcmd() -> Command { - Command::new(CMD_ACCOUNT) - .about("Subcommand to manage accounts") - .long_about("Subcommand to manage accounts like configure, list or sync") - .aliases(["accounts", "acc"]) - .subcommand_required(true) - .arg_required_else_help(true) - .subcommand( - Command::new(CMD_CONFIGURE) - .about("Configure the given account") - .aliases(["config", "conf", "cfg"]) - .arg(reset_flag()) - .arg(folder::args::source_arg( - "Define the account to be configured", - )), - ) - .subcommand( - Command::new(CMD_LIST) - .about("List all accounts") - .long_about("List all accounts that are set up in the configuration file") - .arg(table::args::max_width()), - ) - .subcommand( - Command::new(CMD_SYNC) - .about("Synchronize the given account locally") - .arg(folder::args::all_arg("Synchronize all folders")) - .arg(folder::args::include_arg( - "Synchronize only the given folders", - )) - .arg(folder::args::exclude_arg( - "Synchronize all folders except the given ones", - )) - .arg(dry_run()), - ) -} - -/// Represents the user account name argument. This argument allows -/// the user to select a different account than the default one. -pub fn global_args() -> impl IntoIterator { - [Arg::new(ARG_ACCOUNT) - .help("Override the default account") - .long_help( - "Override the default account - -The given account will be used by default for all other commands (when applicable).", - ) - .long("account") - .short('a') - .global(true) - .value_name("name")] -} - -/// Represents the user account name argument parser. -pub fn parse_global_arg(matches: &ArgMatches) -> Option<&str> { - matches.get_one::(ARG_ACCOUNT).map(String::as_str) -} - -/// Represents the user account sync dry run flag. This flag allows -/// the user to see the changes of a sync without applying them. -pub fn dry_run() -> Arg { - Arg::new(ARG_DRY_RUN) - .help("Do not apply changes of the synchronization") - .long_help( - "Do not apply changes of the synchronization. -Changes can be visualized with the RUST_LOG=trace environment variable.", - ) - .short('d') - .long("dry-run") - .action(ArgAction::SetTrue) -} - -/// Represents the user account sync dry run flag parser. -pub fn parse_dry_run_arg(m: &ArgMatches) -> bool { - m.get_flag(ARG_DRY_RUN) -} - -pub fn reset_flag() -> Arg { - Arg::new(ARG_RESET) - .help("Reset the configuration") - .short('r') - .long("reset") - .action(ArgAction::SetTrue) -} - -pub fn parse_reset_flag(m: &ArgMatches) -> bool { - m.get_flag(ARG_RESET) -} diff --git a/src/account/command/configure.rs b/src/account/command/configure.rs new file mode 100644 index 0000000..1ce3fb4 --- /dev/null +++ b/src/account/command/configure.rs @@ -0,0 +1,115 @@ +use anyhow::Result; +use clap::Parser; +#[cfg(feature = "imap")] +use email::imap::config::ImapAuthConfig; +#[cfg(feature = "smtp")] +use email::smtp::config::SmtpAuthConfig; +use log::{debug, info, warn}; + +use crate::{ + config::{ + wizard::{prompt_passwd, prompt_secret}, + TomlConfig, + }, + printer::Printer, +}; + +/// Configure the given account +#[derive(Debug, Parser)] +pub struct Command { + /// The name of the account that needs to be configured + /// + /// The account names are taken from the table at the root level + /// of your TOML configuration file. + #[arg(value_name = "NAME")] + pub account_name: String, + + /// Force the account to reconfigure, even if it is already + /// configured + #[arg(long, short)] + pub force: bool, +} + +impl Command { + pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { + info!("executing account configure command"); + + let (_, account_config) = + config.into_toml_account_config(Some(self.account_name.as_str()))?; + + if self.force { + #[cfg(feature = "imap")] + if let Some(ref config) = account_config.imap { + let reset = match &config.auth { + ImapAuthConfig::Passwd(config) => config.reset(), + ImapAuthConfig::OAuth2(config) => config.reset(), + }; + if let Err(err) = reset { + warn!("error while resetting imap secrets: {err}"); + debug!("error while resetting imap secrets: {err:?}"); + } + } + + #[cfg(feature = "smtp")] + if let Some(ref config) = account_config.smtp { + let reset = match &config.auth { + SmtpAuthConfig::Passwd(config) => config.reset(), + SmtpAuthConfig::OAuth2(config) => config.reset(), + }; + if let Err(err) = reset { + warn!("error while resetting smtp secrets: {err}"); + debug!("error while resetting smtp secrets: {err:?}"); + } + } + + #[cfg(feature = "pgp")] + if let Some(ref config) = account_config.pgp { + account_config.pgp.reset().await?; + } + } + + #[cfg(feature = "imap")] + if let Some(ref config) = account_config.imap { + match &config.auth { + ImapAuthConfig::Passwd(config) => { + config.configure(|| prompt_passwd("IMAP password")).await + } + ImapAuthConfig::OAuth2(config) => { + config + .configure(|| prompt_secret("IMAP OAuth 2.0 client secret")) + .await + } + }?; + } + + #[cfg(feature = "smtp")] + if let Some(ref config) = account_config.smtp { + match &config.auth { + SmtpAuthConfig::Passwd(config) => { + config.configure(|| prompt_passwd("SMTP password")).await + } + SmtpAuthConfig::OAuth2(config) => { + config + .configure(|| prompt_secret("SMTP OAuth 2.0 client secret")) + .await + } + }?; + } + + #[cfg(feature = "pgp")] + if let Some(ref config) = config.pgp { + config + .pgp + .configure(&config.email, || prompt_passwd("PGP secret key password")) + .await?; + } + + printer.print(format!( + "Account {} successfully {}configured!", + self.account_name, + if self.force { "re" } else { "" } + ))?; + + Ok(()) + } +} diff --git a/src/account/command/list.rs b/src/account/command/list.rs new file mode 100644 index 0000000..b0a848e --- /dev/null +++ b/src/account/command/list.rs @@ -0,0 +1,141 @@ +use anyhow::Result; +use clap::Parser; +use log::info; + +use crate::{ + account::Accounts, + config::TomlConfig, + printer::{PrintTableOpts, Printer}, +}; + +/// List all accounts +#[derive(Debug, Parser)] +pub struct Command { + /// Define a maximum width for the table + #[arg(long, short = 'w', name = "PIXELS")] + pub max_width: Option, +} + +impl Command { + pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { + info!("executing account list command"); + + let accounts: Accounts = config.accounts.iter().into(); + + printer.print_table( + Box::new(accounts), + PrintTableOpts { + format: config + .email_reading_format + .as_ref() + .unwrap_or(&Default::default()), + max_width: self.max_width, + }, + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use email::{account::config::AccountConfig, imap::config::ImapConfig}; + use std::{collections::HashMap, fmt::Debug, io}; + use termcolor::ColorSpec; + + use crate::{ + account::TomlAccountConfig, + backend::BackendKind, + printer::{Print, PrintTable, WriteColor}, + }; + + use super::*; + + #[test] + fn it_should_match_cmds_accounts() { + #[derive(Debug, Default, Clone)] + struct StringWriter { + content: String, + } + + impl io::Write for StringWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.content + .push_str(&String::from_utf8(buf.to_vec()).unwrap()); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.content = String::default(); + Ok(()) + } + } + + impl termcolor::WriteColor for StringWriter { + fn supports_color(&self) -> bool { + false + } + + fn set_color(&mut self, _spec: &ColorSpec) -> io::Result<()> { + io::Result::Ok(()) + } + + fn reset(&mut self) -> io::Result<()> { + io::Result::Ok(()) + } + } + + impl WriteColor for StringWriter {} + + #[derive(Debug, Default)] + struct PrinterServiceTest { + pub writer: StringWriter, + } + + impl Printer for PrinterServiceTest { + fn print_table( + &mut self, + data: Box, + opts: PrintTableOpts, + ) -> Result<()> { + data.print_table(&mut self.writer, opts)?; + Ok(()) + } + fn print_log(&mut self, _data: T) -> Result<()> { + unimplemented!() + } + fn print(&mut self, _data: T) -> Result<()> { + unimplemented!() + } + fn is_json(&self) -> bool { + unimplemented!() + } + } + + let mut printer = PrinterServiceTest::default(); + let config = AccountConfig::default(); + let deserialized_config = TomlConfig { + accounts: HashMap::from_iter([( + "account-1".into(), + TomlAccountConfig { + default: Some(true), + backend: Some(BackendKind::Imap), + imap: Some(ImapConfig::default()), + ..Default::default() + }, + )]), + ..TomlConfig::default() + }; + + assert!(list(None, &config, &deserialized_config, &mut printer).is_ok()); + assert_eq!( + concat![ + "\n", + "NAME │BACKEND │DEFAULT \n", + "account-1 │imap │yes \n", + "\n" + ], + printer.writer.content + ); + } +} diff --git a/src/account/command/mod.rs b/src/account/command/mod.rs new file mode 100644 index 0000000..ce0d776 --- /dev/null +++ b/src/account/command/mod.rs @@ -0,0 +1,34 @@ +mod configure; +mod list; +mod sync; + +use anyhow::Result; +use clap::Subcommand; + +use crate::{config::TomlConfig, printer::Printer}; + +/// Subcommand to manage accounts +#[derive(Debug, Subcommand)] +pub enum Command { + /// Configure the given account + #[command(alias = "cfg")] + Configure(configure::Command), + + /// List all exsting accounts + #[command(alias = "lst")] + List(list::Command), + + /// Synchronize the given account locally + #[command()] + Sync(sync::Command), +} + +impl Command { + pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { + match self { + Self::Configure(cmd) => cmd.execute(printer, config).await, + Self::List(cmd) => cmd.execute(printer, config).await, + Self::Sync(cmd) => cmd.execute(printer, config).await, + } + } +} diff --git a/src/account/command/sync.rs b/src/account/command/sync.rs new file mode 100644 index 0000000..7096924 --- /dev/null +++ b/src/account/command/sync.rs @@ -0,0 +1,233 @@ +use anyhow::Result; +use clap::{ArgAction, Parser}; +use email::{ + account::sync::{AccountSyncBuilder, AccountSyncProgressEvent}, + folder::sync::FolderSyncStrategy, +}; +use indicatif::{MultiProgress, ProgressBar, ProgressFinish, ProgressStyle}; +use log::info; +use once_cell::sync::Lazy; +use std::{ + collections::{HashMap, HashSet}, + sync::Mutex, +}; + +use crate::{backend::BackendBuilder, config::TomlConfig, printer::Printer}; + +const MAIN_PROGRESS_STYLE: Lazy = Lazy::new(|| { + ProgressStyle::with_template(" {spinner:.dim} {msg:.dim}\n {wide_bar:.cyan/blue} \n").unwrap() +}); + +const SUB_PROGRESS_STYLE: Lazy = Lazy::new(|| { + ProgressStyle::with_template( + " {prefix:.bold} — {wide_msg:.dim} \n {wide_bar:.black/black} {percent}% ", + ) + .unwrap() +}); + +const SUB_PROGRESS_DONE_STYLE: Lazy = Lazy::new(|| { + ProgressStyle::with_template(" {prefix:.bold} \n {wide_bar:.green} {percent}% ").unwrap() +}); + +#[derive(Debug, Parser)] +pub struct Command { + /// The name of the account that needs to be synchronized + /// + /// The account names are taken from the table at the root level + /// of your TOML configuration file. + #[arg(value_name = "ACCOUNT")] + pub account_name: String, + + /// Run the synchronization without applying changes + /// + /// Instead, a report will be printed to stdout containing all the + /// changes the synchronization plan to do. + #[arg(long, short)] + pub dry_run: bool, + + #[arg(long, short = 'f', value_name = "FOLDER", action = ArgAction::Append, conflicts_with = "exclude_folder", conflicts_with = "all_folders")] + pub include_folder: Vec, + + #[arg(long, short = 'x', value_name = "FOLDER", action = ArgAction::Append, conflicts_with = "include_folder", conflicts_with = "all_folders")] + pub exclude_folder: Vec, + + #[arg( + long, + short = 'A', + conflicts_with = "include_folder", + conflicts_with = "exclude_folder" + )] + pub all_folders: bool, +} + +impl Command { + pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { + info!("executing account sync command"); + + let included_folders = HashSet::from_iter(self.include_folder); + let excluded_folders = HashSet::from_iter(self.exclude_folder); + + let strategy = if !included_folders.is_empty() { + Some(FolderSyncStrategy::Include(included_folders)) + } else if !excluded_folders.is_empty() { + Some(FolderSyncStrategy::Exclude(excluded_folders)) + } else if self.all_folders { + Some(FolderSyncStrategy::All) + } else { + None + }; + + let (toml_account_config, account_config) = config + .clone() + .into_account_configs(Some(self.account_name.as_str()), true)?; + + let backend_builder = + BackendBuilder::new(toml_account_config, account_config.clone(), false).await?; + let sync_builder = AccountSyncBuilder::new(backend_builder.into()) + .await? + .with_some_folders_strategy(strategy) + .with_dry_run(self.dry_run); + + if self.dry_run { + let report = sync_builder.sync().await?; + let mut hunks_count = report.folders_patch.len(); + + if !report.folders_patch.is_empty() { + printer.print_log("Folders patch:")?; + for (hunk, _) in report.folders_patch { + printer.print_log(format!(" - {hunk}"))?; + } + printer.print_log("")?; + } + + if !report.emails_patch.is_empty() { + printer.print_log("Envelopes patch:")?; + for (hunk, _) in report.emails_patch { + hunks_count += 1; + printer.print_log(format!(" - {hunk}"))?; + } + printer.print_log("")?; + } + + printer.print(format!( + "Estimated patch length for account to be synchronized: {hunks_count}", + ))?; + } else if printer.is_json() { + sync_builder.sync().await?; + printer.print("Account successfully synchronized!")?; + } else { + let multi = MultiProgress::new(); + let sub_progresses = Mutex::new(HashMap::new()); + let main_progress = multi.add( + ProgressBar::new(100) + .with_style(MAIN_PROGRESS_STYLE.clone()) + .with_message("Synchronizing folders…"), + ); + + // Force the progress bar to show + main_progress.set_position(0); + + let report = sync_builder + .with_on_progress(move |evt| { + use AccountSyncProgressEvent::*; + Ok(match evt { + ApplyFolderPatches(..) => { + main_progress.inc(3); + } + ApplyEnvelopePatches(patches) => { + let mut envelopes_progresses = sub_progresses.lock().unwrap(); + let patches_len = + patches.values().fold(0, |sum, patch| sum + patch.len()); + main_progress.set_length((110 * patches_len / 100) as u64); + main_progress.set_position((5 * patches_len / 100) as u64); + main_progress.set_message("Synchronizing envelopes…"); + + for (folder, patch) in patches { + let progress = ProgressBar::new(patch.len() as u64) + .with_style(SUB_PROGRESS_STYLE.clone()) + .with_prefix(folder.clone()) + .with_finish(ProgressFinish::AndClear); + let progress = multi.add(progress); + envelopes_progresses.insert(folder, progress.clone()); + } + } + ApplyEnvelopeHunk(hunk) => { + main_progress.inc(1); + let mut progresses = sub_progresses.lock().unwrap(); + if let Some(progress) = progresses.get_mut(hunk.folder()) { + progress.inc(1); + if progress.position() == (progress.length().unwrap() - 1) { + progress.set_style(SUB_PROGRESS_DONE_STYLE.clone()) + } else { + progress.set_message(format!("{hunk}…")); + } + } + } + ApplyEnvelopeCachePatch(_patch) => { + main_progress.set_length(100); + main_progress.set_position(95); + main_progress.set_message("Saving cache database…"); + } + ExpungeFolders(folders) => { + let mut progresses = sub_progresses.lock().unwrap(); + for progress in progresses.values() { + progress.finish_and_clear() + } + progresses.clear(); + + main_progress.set_position(100); + main_progress + .set_message(format!("Expunging {} folders…", folders.len())); + } + _ => (), + }) + }) + .sync() + .await?; + + let folders_patch_err = report + .folders_patch + .iter() + .filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err))) + .collect::>(); + if !folders_patch_err.is_empty() { + printer.print_log("")?; + printer.print_log("Errors occurred while applying the folders patch:")?; + folders_patch_err + .iter() + .try_for_each(|(hunk, err)| printer.print_log(format!(" - {hunk}: {err}")))?; + } + + if let Some(err) = report.folders_cache_patch.1 { + printer.print_log("")?; + printer.print_log(format!( + "Error occurred while applying the folder cache patch: {err}" + ))?; + } + + let envelopes_patch_err = report + .emails_patch + .iter() + .filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err))) + .collect::>(); + if !envelopes_patch_err.is_empty() { + printer.print_log("")?; + printer.print_log("Errors occurred while applying the envelopes patch:")?; + for (hunk, err) in folders_patch_err { + printer.print_log(format!(" - {hunk}: {err}"))?; + } + } + + if let Some(err) = report.emails_cache_patch.1 { + printer.print_log("")?; + printer.print_log(format!( + "Error occurred while applying the envelopes cache patch: {err}" + ))?; + } + + printer.print("Account successfully synchronized!")?; + } + + Ok(()) + } +} diff --git a/src/account/handlers.rs b/src/account/handlers.rs deleted file mode 100644 index f1f15be..0000000 --- a/src/account/handlers.rs +++ /dev/null @@ -1,401 +0,0 @@ -//! Account handlers module. -//! -//! This module gathers all account actions triggered by the CLI. - -use anyhow::Result; -use email::account::{ - config::AccountConfig, - sync::{AccountSyncBuilder, AccountSyncProgressEvent}, -}; -#[cfg(feature = "imap")] -use email::imap::config::ImapAuthConfig; -#[cfg(feature = "smtp")] -use email::smtp::config::SmtpAuthConfig; -use indicatif::{MultiProgress, ProgressBar, ProgressFinish, ProgressStyle}; -use log::{debug, info, trace, warn}; -use once_cell::sync::Lazy; -use std::{collections::HashMap, sync::Mutex}; - -use crate::{ - account::Accounts, - backend::BackendContextBuilder, - config::{ - wizard::{prompt_passwd, prompt_secret}, - TomlConfig, - }, - printer::{PrintTableOpts, Printer}, -}; - -use super::TomlAccountConfig; - -const MAIN_PROGRESS_STYLE: Lazy = Lazy::new(|| { - ProgressStyle::with_template(" {spinner:.dim} {msg:.dim}\n {wide_bar:.cyan/blue} \n").unwrap() -}); - -const SUB_PROGRESS_STYLE: Lazy = Lazy::new(|| { - ProgressStyle::with_template( - " {prefix:.bold} — {wide_msg:.dim} \n {wide_bar:.black/black} {percent}% ", - ) - .unwrap() -}); - -const SUB_PROGRESS_DONE_STYLE: Lazy = Lazy::new(|| { - ProgressStyle::with_template(" {prefix:.bold} \n {wide_bar:.green} {percent}% ").unwrap() -}); - -/// Configure the current selected account -pub async fn configure(config: &TomlAccountConfig, reset: bool) -> Result<()> { - info!("entering the configure account handler"); - - if reset { - #[cfg(feature = "imap")] - if let Some(ref config) = config.imap { - let reset = match &config.auth { - ImapAuthConfig::Passwd(config) => config.reset(), - ImapAuthConfig::OAuth2(config) => config.reset(), - }; - if let Err(err) = reset { - warn!("error while resetting imap secrets: {err}"); - debug!("error while resetting imap secrets: {err:?}"); - } - } - - #[cfg(feature = "smtp")] - if let Some(ref config) = config.smtp { - let reset = match &config.auth { - SmtpAuthConfig::Passwd(config) => config.reset(), - SmtpAuthConfig::OAuth2(config) => config.reset(), - }; - if let Err(err) = reset { - warn!("error while resetting smtp secrets: {err}"); - debug!("error while resetting smtp secrets: {err:?}"); - } - } - - #[cfg(feature = "pgp")] - if let Some(ref config) = config.pgp { - config.pgp.reset().await?; - } - } - - #[cfg(feature = "imap")] - if let Some(ref config) = config.imap { - match &config.auth { - ImapAuthConfig::Passwd(config) => { - config.configure(|| prompt_passwd("IMAP password")).await - } - ImapAuthConfig::OAuth2(config) => { - config - .configure(|| prompt_secret("IMAP OAuth 2.0 client secret")) - .await - } - }?; - } - - #[cfg(feature = "smtp")] - if let Some(ref config) = config.smtp { - match &config.auth { - SmtpAuthConfig::Passwd(config) => { - config.configure(|| prompt_passwd("SMTP password")).await - } - SmtpAuthConfig::OAuth2(config) => { - config - .configure(|| prompt_secret("SMTP OAuth 2.0 client secret")) - .await - } - }?; - } - - #[cfg(feature = "pgp")] - if let Some(ref config) = config.pgp { - config - .pgp - .configure(&config.email, || prompt_passwd("PGP secret key password")) - .await?; - } - - println!( - "Account successfully {}configured!", - if reset { "re" } else { "" } - ); - - Ok(()) -} - -/// Lists all accounts. -pub fn list<'a, P: Printer>( - max_width: Option, - config: &AccountConfig, - deserialized_config: &TomlConfig, - printer: &mut P, -) -> Result<()> { - info!("entering the list accounts handler"); - - let accounts: Accounts = deserialized_config.accounts.iter().into(); - trace!("accounts: {:?}", accounts); - - printer.print_table( - Box::new(accounts), - PrintTableOpts { - format: &config.email_reading_format, - max_width, - }, - )?; - - info!("<< account list handler"); - Ok(()) -} - -/// Synchronizes the account defined using argument `-a|--account`. If -/// no account given, synchronizes the default one. -pub async fn sync( - printer: &mut P, - sync_builder: AccountSyncBuilder, - dry_run: bool, -) -> Result<()> { - info!("entering the sync accounts handler"); - trace!("dry run: {dry_run}"); - - if dry_run { - let report = sync_builder.sync().await?; - let mut hunks_count = report.folders_patch.len(); - - if !report.folders_patch.is_empty() { - printer.print_log("Folders patch:")?; - for (hunk, _) in report.folders_patch { - printer.print_log(format!(" - {hunk}"))?; - } - printer.print_log("")?; - } - - if !report.emails_patch.is_empty() { - printer.print_log("Envelopes patch:")?; - for (hunk, _) in report.emails_patch { - hunks_count += 1; - printer.print_log(format!(" - {hunk}"))?; - } - printer.print_log("")?; - } - - printer.print(format!( - "Estimated patch length for account to be synchronized: {hunks_count}", - ))?; - } else if printer.is_json() { - sync_builder.sync().await?; - printer.print("Account successfully synchronized!")?; - } else { - let multi = MultiProgress::new(); - let sub_progresses = Mutex::new(HashMap::new()); - let main_progress = multi.add( - ProgressBar::new(100) - .with_style(MAIN_PROGRESS_STYLE.clone()) - .with_message("Synchronizing folders…"), - ); - - // Force the progress bar to show - main_progress.set_position(0); - - let report = sync_builder - .with_on_progress(move |evt| { - use AccountSyncProgressEvent::*; - Ok(match evt { - ApplyFolderPatches(..) => { - main_progress.inc(3); - } - ApplyEnvelopePatches(patches) => { - let mut envelopes_progresses = sub_progresses.lock().unwrap(); - let patches_len = patches.values().fold(0, |sum, patch| sum + patch.len()); - main_progress.set_length((110 * patches_len / 100) as u64); - main_progress.set_position((5 * patches_len / 100) as u64); - main_progress.set_message("Synchronizing envelopes…"); - - for (folder, patch) in patches { - let progress = ProgressBar::new(patch.len() as u64) - .with_style(SUB_PROGRESS_STYLE.clone()) - .with_prefix(folder.clone()) - .with_finish(ProgressFinish::AndClear); - let progress = multi.add(progress); - envelopes_progresses.insert(folder, progress.clone()); - } - } - ApplyEnvelopeHunk(hunk) => { - main_progress.inc(1); - let mut progresses = sub_progresses.lock().unwrap(); - if let Some(progress) = progresses.get_mut(hunk.folder()) { - progress.inc(1); - if progress.position() == (progress.length().unwrap() - 1) { - progress.set_style(SUB_PROGRESS_DONE_STYLE.clone()) - } else { - progress.set_message(format!("{hunk}…")); - } - } - } - ApplyEnvelopeCachePatch(_patch) => { - main_progress.set_length(100); - main_progress.set_position(95); - main_progress.set_message("Saving cache database…"); - } - ExpungeFolders(folders) => { - let mut progresses = sub_progresses.lock().unwrap(); - for progress in progresses.values() { - progress.finish_and_clear() - } - progresses.clear(); - - main_progress.set_position(100); - main_progress.set_message(format!("Expunging {} folders…", folders.len())); - } - _ => (), - }) - }) - .sync() - .await?; - - let folders_patch_err = report - .folders_patch - .iter() - .filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err))) - .collect::>(); - if !folders_patch_err.is_empty() { - printer.print_log("")?; - printer.print_log("Errors occurred while applying the folders patch:")?; - folders_patch_err - .iter() - .try_for_each(|(hunk, err)| printer.print_log(format!(" - {hunk}: {err}")))?; - } - - if let Some(err) = report.folders_cache_patch.1 { - printer.print_log("")?; - printer.print_log(format!( - "Error occurred while applying the folder cache patch: {err}" - ))?; - } - - let envelopes_patch_err = report - .emails_patch - .iter() - .filter_map(|(hunk, err)| err.as_ref().map(|err| (hunk, err))) - .collect::>(); - if !envelopes_patch_err.is_empty() { - printer.print_log("")?; - printer.print_log("Errors occurred while applying the envelopes patch:")?; - for (hunk, err) in folders_patch_err { - printer.print_log(format!(" - {hunk}: {err}"))?; - } - } - - if let Some(err) = report.emails_cache_patch.1 { - printer.print_log("")?; - printer.print_log(format!( - "Error occurred while applying the envelopes cache patch: {err}" - ))?; - } - - printer.print("Account successfully synchronized!")?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use email::{account::config::AccountConfig, imap::config::ImapConfig}; - use std::{collections::HashMap, fmt::Debug, io}; - use termcolor::ColorSpec; - - use crate::{ - account::TomlAccountConfig, - backend::BackendKind, - printer::{Print, PrintTable, WriteColor}, - }; - - use super::*; - - #[test] - fn it_should_match_cmds_accounts() { - #[derive(Debug, Default, Clone)] - struct StringWriter { - content: String, - } - - impl io::Write for StringWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.content - .push_str(&String::from_utf8(buf.to_vec()).unwrap()); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - self.content = String::default(); - Ok(()) - } - } - - impl termcolor::WriteColor for StringWriter { - fn supports_color(&self) -> bool { - false - } - - fn set_color(&mut self, _spec: &ColorSpec) -> io::Result<()> { - io::Result::Ok(()) - } - - fn reset(&mut self) -> io::Result<()> { - io::Result::Ok(()) - } - } - - impl WriteColor for StringWriter {} - - #[derive(Debug, Default)] - struct PrinterServiceTest { - pub writer: StringWriter, - } - - impl Printer for PrinterServiceTest { - fn print_table( - &mut self, - data: Box, - opts: PrintTableOpts, - ) -> Result<()> { - data.print_table(&mut self.writer, opts)?; - Ok(()) - } - fn print_log(&mut self, _data: T) -> Result<()> { - unimplemented!() - } - fn print(&mut self, _data: T) -> Result<()> { - unimplemented!() - } - fn is_json(&self) -> bool { - unimplemented!() - } - } - - let mut printer = PrinterServiceTest::default(); - let config = AccountConfig::default(); - let deserialized_config = TomlConfig { - accounts: HashMap::from_iter([( - "account-1".into(), - TomlAccountConfig { - default: Some(true), - backend: Some(BackendKind::Imap), - imap: Some(ImapConfig::default()), - ..Default::default() - }, - )]), - ..TomlConfig::default() - }; - - assert!(list(None, &config, &deserialized_config, &mut printer).is_ok()); - assert_eq!( - concat![ - "\n", - "NAME │BACKEND │DEFAULT \n", - "account-1 │imap │yes \n", - "\n" - ], - printer.writer.content - ); - } -} diff --git a/src/account/mod.rs b/src/account/mod.rs index 1c5708d..b463195 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -1,6 +1,5 @@ -pub mod args; +pub mod command; pub mod config; -pub mod handlers; pub(crate) mod wizard; use anyhow::Result; @@ -45,7 +44,7 @@ impl Table for Account { fn head() -> Row { Row::new() .cell(Cell::new("NAME").shrinkable().bold().underline().white()) - .cell(Cell::new("BACKEND").bold().underline().white()) + .cell(Cell::new("BACKENDS").bold().underline().white()) .cell(Cell::new("DEFAULT").bold().underline().white()) } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..481320e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,113 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +use crate::{ + account::command as account, + completion::command as completion, + config::{self, TomlConfig}, + man::command as man, + output::{ColorFmt, OutputFmt}, + printer::Printer, +}; + +#[derive(Parser, Debug)] +#[command( + name = "himalaya", + author, + version, + about, + propagate_version = true, + infer_subcommands = true +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, + + /// Override the default configuration file path + /// + /// The given path is shell-expanded then canonicalized (if + /// applicable). If the path does not point to a valid file, the + /// wizard will propose to assist you in the creation of the + /// configuration file. + #[arg(long, short, value_name = "PATH", global = true, value_parser = config::path_parser)] + pub config: Option, + + /// Customize the output format + /// + /// The output format determine how to display commands output to + /// the terminal. + /// + /// The possible values are: + /// + /// - json: output will be in a form of a JSON-compatible object + /// + /// - plain: output will be in a form of either a plain text or + /// table, depending on the command + #[arg( + long, + short, + value_name = "FORMAT", + global = true, + value_enum, + default_value_t = Default::default(), + )] + pub output: OutputFmt, + + /// Control when to use colors + /// + /// The default setting is 'auto', which means himalaya will try + /// to guess when to use colors. For example, if himalaya is + /// printing to a terminal, then it will use colors, but if it is + /// redirected to a file or a pipe, then it will suppress color + /// output. himalaya will suppress color output in some other + /// circumstances as well. For example, if the TERM environment + /// variable is not set or set to 'dumb', then himalaya will not + /// use colors. + /// + /// The possible values are: + /// + /// - never: colors will never be used + /// + /// - always: colors will always be used regardless of where output is sent + /// + /// - ansi: like 'always', but emits ANSI escapes (even in a Windows console) + /// + /// - auto: himalaya tries to be smart + #[arg( + long, + short = 'C', + value_name = "MODE", + global = true, + value_enum, + default_value_t = Default::default(), + )] + pub color: ColorFmt, +} + +/// Top-level CLI commands. +#[derive(Subcommand, Debug)] +pub enum Command { + /// Subcommand to manage accounts + #[command(subcommand)] + Account(account::Command), + + /// Generate all man pages to the given directory + #[command(arg_required_else_help = true, alias = "mans")] + Man(man::Command), + + /// Print completion script for the given shell to stdout + #[command(arg_required_else_help = true, aliases = ["completions", "compl", "comp"])] + Completion(completion::Command), +} + +impl Command { + pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { + match self { + Self::Account(cmd) => cmd.execute(printer, config).await, + Self::Man(cmd) => cmd.execute(printer).await, + Self::Completion(cmd) => cmd.execute(printer).await, + } + } +} diff --git a/src/completion/command.rs b/src/completion/command.rs index 83c4a48..aecefcc 100644 --- a/src/completion/command.rs +++ b/src/completion/command.rs @@ -1,10 +1,32 @@ -use clap::{value_parser, Parser}; +use anyhow::Result; +use clap::{value_parser, CommandFactory, Parser}; use clap_complete::Shell; +use log::info; +use std::io; + +use crate::{cli::Cli, printer::Printer}; /// Print completion script for the given shell to stdout #[derive(Debug, Parser)] -pub struct Generate { +pub struct Command { /// Shell that completion script should be generated for #[arg(value_parser = value_parser!(Shell))] pub shell: Shell, } + +impl Command { + pub async fn execute(self, printer: &mut impl Printer) -> Result<()> { + info!("executing completion generate command"); + + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + clap_complete::generate(self.shell, &mut cmd, name, &mut io::stdout()); + + printer.print(format!( + "Shell script successfully generated for shell {}!", + self.shell + ))?; + + Ok(()) + } +} diff --git a/src/completion/handler.rs b/src/completion/handler.rs deleted file mode 100644 index b804a47..0000000 --- a/src/completion/handler.rs +++ /dev/null @@ -1,18 +0,0 @@ -use anyhow::Result; -use clap::Command; -use clap_complete::Shell; -use std::io::stdout; - -use crate::printer::Printer; - -pub fn generate(printer: &mut impl Printer, mut cmd: Command, shell: Shell) -> Result<()> { - let name = cmd.get_name().to_string(); - - clap_complete::generate(shell, &mut cmd, name, &mut stdout()); - - printer.print(format!( - "Shell script successfully generated for shell {shell}!" - ))?; - - Ok(()) -} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index a2f4939..9fe7961 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -1,2 +1 @@ pub mod command; -pub mod handler; diff --git a/src/config/mod.rs b/src/config/mod.rs index e54f0df..25559c1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,8 +1,3 @@ -//! Deserialized config module. -//! -//! This module contains the raw deserialized representation of the -//! user configuration file. - pub mod args; pub mod prelude; pub mod wizard; @@ -16,6 +11,7 @@ use email::{ email::config::{EmailHooks, EmailTextPlainFormat}, }; use serde::{Deserialize, Serialize}; +use shellexpand_utils::{canonicalize, expand}; use std::{ collections::HashMap, fs, @@ -162,12 +158,10 @@ impl TomlConfig { .filter(|p| p.exists()) } - /// Build account configurations from a given account name. - pub fn into_account_configs( - self, + pub fn into_toml_account_config( + &self, account_name: Option<&str>, - disable_cache: bool, - ) -> Result<(TomlAccountConfig, AccountConfig)> { + ) -> Result<(String, TomlAccountConfig)> { let (account_name, mut toml_account_config) = match account_name { Some("default") | Some("") | None => self .accounts @@ -200,6 +194,18 @@ impl TomlConfig { .replace_undefined_keyring_entries(&account_name); } + Ok((account_name, toml_account_config)) + } + + /// Build account configurations from a given account name. + pub fn into_account_configs( + self, + account_name: Option<&str>, + disable_cache: bool, + ) -> Result<(TomlAccountConfig, AccountConfig)> { + let (account_name, mut toml_account_config) = + self.into_toml_account_config(account_name)?; + if let Some(true) = toml_account_config.sync { if !disable_cache { toml_account_config.backend = Some(BackendKind::MaildirForSync); @@ -267,6 +273,15 @@ impl TomlConfig { } } +/// Parse a configuration file path as [`PathBuf`]. +/// +/// The path is shell-expanded then canonicalized (if applicable). +pub fn path_parser(path: &str) -> Result { + expand::try_path(path) + .map(canonicalize::path) + .map_err(|err| err.to_string()) +} + #[cfg(test)] mod tests { use email::{ diff --git a/src/folder/args.rs b/src/folder/args.rs index f4bb7bf..f9d0c7e 100644 --- a/src/folder/args.rs +++ b/src/folder/args.rs @@ -121,11 +121,6 @@ pub fn all_arg(help: &'static str) -> Arg { .conflicts_with(ARG_EXCLUDE) } -/// Represents the all folders argument parser. -pub fn parse_all_arg(m: &ArgMatches) -> bool { - m.get_flag(ARG_ALL) -} - /// Represents the folders to include argument. pub fn include_arg(help: &'static str) -> Arg { Arg::new(ARG_INCLUDE) @@ -141,14 +136,6 @@ pub fn include_arg(help: &'static str) -> Arg { .conflicts_with(ARG_EXCLUDE) } -/// Represents the folders to include argument parser. -pub fn parse_include_arg(m: &ArgMatches) -> HashSet { - m.get_many::(ARG_INCLUDE) - .unwrap_or_default() - .map(ToOwned::to_owned) - .collect() -} - /// Represents the folders to exclude argument. pub fn exclude_arg(help: &'static str) -> Arg { Arg::new(ARG_EXCLUDE) diff --git a/src/lib.rs b/src/lib.rs index 79c29ef..8cb19cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod account; pub mod backend; pub mod cache; +pub mod cli; pub mod completion; pub mod config; pub mod email; diff --git a/src/main.rs b/src/main.rs index a4d7330..d80c198 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,361 +1,7 @@ -use ::email::account::{config::DEFAULT_INBOX_FOLDER, sync::AccountSyncBuilder}; -use anyhow::{anyhow, Context, Result}; -use clap::{Command, CommandFactory, Parser, Subcommand}; +use anyhow::Result; +use clap::Parser; use env_logger::{Builder as LoggerBuilder, Env, DEFAULT_FILTER_ENV}; -use log::{debug, warn}; -use std::env; -use url::Url; - -use himalaya::{ - account, - backend::{Backend, BackendBuilder}, - cache, completion, - config::{self, TomlConfig}, - envelope, flag, folder, man, message, output, - printer::StdoutPrinter, - template, -}; - -fn _create_app() -> Command { - Command::new(env!("CARGO_PKG_NAME")) - .version(env!("CARGO_PKG_VERSION")) - .about(env!("CARGO_PKG_DESCRIPTION")) - .author(env!("CARGO_PKG_AUTHORS")) - .propagate_version(true) - .infer_subcommands(true) - .args(config::args::global_args()) - .args(account::args::global_args()) - .args(folder::args::global_args()) - .args(cache::args::global_args()) - .args(output::args::global_args()) - .subcommand(account::args::subcmd()) - .subcommand(folder::args::subcmd()) - .subcommand(envelope::args::subcmd()) - .subcommand(flag::args::subcmd()) - .subcommand(message::args::subcmd()) - .subcommand(template::args::subcmd()) -} - -#[tokio::main] -async fn _old_main() -> Result<()> { - #[cfg(not(target_os = "windows"))] - if let Err((_, err)) = coredump::register_panic_handler() { - warn!("cannot register custom panic handler: {err}"); - debug!("cannot register custom panic handler: {err:?}"); - } - - let default_env_filter = env_logger::DEFAULT_FILTER_ENV; - env_logger::init_from_env(env_logger::Env::default().filter_or(default_env_filter, "off")); - - // check mailto command before app initialization - let raw_args: Vec = env::args().collect(); - if raw_args.len() > 1 && raw_args[1].starts_with("mailto:") { - let url = Url::parse(&raw_args[1])?; - let (toml_account_config, account_config) = TomlConfig::from_default_paths() - .await? - .into_account_configs(None, false)?; - let backend_builder = - BackendBuilder::new(toml_account_config, account_config.clone(), true).await?; - let backend = backend_builder.build().await?; - let mut printer = StdoutPrinter::default(); - - return message::handlers::mailto(&account_config, &backend, &mut printer, &url).await; - } - - let app = _create_app(); - let m = app.get_matches(); - - let some_config_path = config::args::parse_global_arg(&m); - let some_account_name = account::args::parse_global_arg(&m); - let disable_cache = cache::args::parse_disable_cache_arg(&m); - let folder = folder::args::parse_global_source_arg(&m); - - let toml_config = TomlConfig::from_some_path_or_default(some_config_path).await?; - - let mut printer = StdoutPrinter::try_from(&m)?; - - // FIXME - // #[cfg(feature = "imap")] - // if let BackendConfig::Imap(imap_config) = &account_config.backend { - // let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - // match imap::args::matches(&m)? { - // Some(imap::args::Cmd::Notify(keepalive)) => { - // let backend = - // ImapBackend::new(account_config.clone(), imap_config.clone(), None).await?; - // imap::handlers::notify(&mut backend, &folder, keepalive).await?; - // return Ok(()); - // } - // Some(imap::args::Cmd::Watch(keepalive)) => { - // let backend = - // ImapBackend::new(account_config.clone(), imap_config.clone(), None).await?; - // imap::handlers::watch(&mut backend, &folder, keepalive).await?; - // return Ok(()); - // } - // _ => (), - // } - // } - - match account::args::matches(&m)? { - Some(account::args::Cmd::List(max_width)) => { - let (_, account_config) = toml_config - .clone() - .into_account_configs(some_account_name, disable_cache)?; - return account::handlers::list(max_width, &account_config, &toml_config, &mut printer); - } - Some(account::args::Cmd::Sync(strategy, dry_run)) => { - let (toml_account_config, account_config) = toml_config - .clone() - .into_account_configs(some_account_name, true)?; - let backend_builder = - BackendBuilder::new(toml_account_config, account_config.clone(), false).await?; - let sync_builder = AccountSyncBuilder::new(backend_builder.into()) - .await? - .with_some_folders_strategy(strategy) - .with_dry_run(dry_run); - return account::handlers::sync(&mut printer, sync_builder, dry_run).await; - } - Some(account::args::Cmd::Configure(reset)) => { - let (toml_account_config, _) = toml_config - .clone() - .into_account_configs(some_account_name, disable_cache)?; - return account::handlers::configure(&toml_account_config, reset).await; - } - _ => (), - } - - let (toml_account_config, account_config) = toml_config - .clone() - .into_account_configs(some_account_name, disable_cache)?; - - // checks folder commands - match folder::args::matches(&m)? { - Some(folder::args::Cmd::Create) => { - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - let folder = folder - .ok_or_else(|| anyhow!("the folder argument is missing")) - .context("cannot create folder")?; - return folder::handlers::create(&mut printer, &backend, &folder).await; - } - Some(folder::args::Cmd::List(max_width)) => { - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return folder::handlers::list(&account_config, &mut printer, &backend, max_width) - .await; - } - Some(folder::args::Cmd::Expunge) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return folder::handlers::expunge(&mut printer, &backend, &folder).await; - } - Some(folder::args::Cmd::Delete) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return folder::handlers::delete(&mut printer, &backend, &folder).await; - } - _ => (), - } - - match envelope::args::matches(&m)? { - Some(envelope::args::Cmd::List(max_width, page_size, page)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return envelope::handlers::list( - &account_config, - &mut printer, - &backend, - &folder, - max_width, - page_size, - page, - ) - .await; - } - _ => (), - } - - match flag::args::matches(&m)? { - Some(flag::args::Cmd::Set(ids, ref flags)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return flag::handlers::set(&mut printer, &backend, &folder, ids, flags).await; - } - Some(flag::args::Cmd::Add(ids, ref flags)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return flag::handlers::add(&mut printer, &backend, &folder, ids, flags).await; - } - Some(flag::args::Cmd::Remove(ids, ref flags)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return flag::handlers::remove(&mut printer, &backend, &folder, ids, flags).await; - } - _ => (), - } - - match message::args::matches(&m)? { - Some(message::args::Cmd::Attachments(ids)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return message::handlers::attachments( - &account_config, - &mut printer, - &backend, - &folder, - ids, - ) - .await; - } - Some(message::args::Cmd::Copy(ids, to_folder)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return message::handlers::copy(&mut printer, &backend, &folder, to_folder, ids).await; - } - Some(message::args::Cmd::Delete(ids)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return message::handlers::delete(&mut printer, &backend, &folder, ids).await; - } - Some(message::args::Cmd::Forward(id, headers, body)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; - return message::handlers::forward( - &account_config, - &mut printer, - &backend, - &folder, - id, - headers, - body, - ) - .await; - } - Some(message::args::Cmd::Move(ids, to_folder)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return message::handlers::move_(&mut printer, &backend, &folder, to_folder, ids).await; - } - Some(message::args::Cmd::Read(ids, text_mime, raw, headers)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return message::handlers::read( - &account_config, - &mut printer, - &backend, - &folder, - ids, - text_mime, - raw, - headers, - ) - .await; - } - Some(message::args::Cmd::Reply(id, all, headers, body)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; - return message::handlers::reply( - &account_config, - &mut printer, - &backend, - &folder, - id, - all, - headers, - body, - ) - .await; - } - Some(message::args::Cmd::Save(raw_email)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return message::handlers::save(&mut printer, &backend, &folder, raw_email).await; - } - Some(message::args::Cmd::Send(raw_email)) => { - let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; - return message::handlers::send(&account_config, &mut printer, &backend, raw_email) - .await; - } - Some(message::args::Cmd::Write(headers, body)) => { - let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; - return message::handlers::write( - &account_config, - &mut printer, - &backend, - headers, - body, - ) - .await; - } - _ => (), - } - - match template::args::matches(&m)? { - Some(template::args::Cmd::Forward(id, headers, body)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return template::handlers::forward( - &account_config, - &mut printer, - &backend, - &folder, - id, - headers, - body, - ) - .await; - } - Some(template::args::Cmd::Write(headers, body)) => { - return template::handlers::write(&account_config, &mut printer, headers, body).await; - } - Some(template::args::Cmd::Reply(id, all, headers, body)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return template::handlers::reply( - &account_config, - &mut printer, - &backend, - &folder, - id, - all, - headers, - body, - ) - .await; - } - Some(template::args::Cmd::Save(template)) => { - let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); - let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; - return template::handlers::save( - &account_config, - &mut printer, - &backend, - &folder, - template, - ) - .await; - } - Some(template::args::Cmd::Send(template)) => { - let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; - return template::handlers::send(&account_config, &mut printer, &backend, template) - .await; - } - _ => (), - } - - Ok(()) -} - -#[derive(Parser, Debug)] -#[command(name= "himalaya", author, version, about, long_about = None, propagate_version = true)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - Man(man::command::Generate), - #[command(aliases = ["completions", "compl", "comp"])] - Completion(completion::command::Generate), -} +use himalaya::{cli::Cli, config::TomlConfig, printer::StdoutPrinter}; #[tokio::main] async fn main() -> Result<()> { @@ -364,12 +10,305 @@ async fn main() -> Result<()> { .format_timestamp(None) .init(); - let mut printer = StdoutPrinter::default(); + let cli = Cli::parse(); - match Cli::parse().command { - Commands::Man(cmd) => man::handler::generate(&mut printer, Cli::command(), cmd.dir), - Commands::Completion(cmd) => { - completion::handler::generate(&mut printer, Cli::command(), cmd.shell) - } - } + let mut printer = StdoutPrinter::new(cli.output, cli.color); + let config = TomlConfig::from_some_path_or_default(cli.config.as_ref()).await?; + + cli.command.execute(&mut printer, &config).await } + +// fn create_app() -> clap::Command { +// clap::Command::new(env!("CARGO_PKG_NAME")) +// .version(env!("CARGO_PKG_VERSION")) +// .about(env!("CARGO_PKG_DESCRIPTION")) +// .author(env!("CARGO_PKG_AUTHORS")) +// .propagate_version(true) +// .infer_subcommands(true) +// .args(folder::args::global_args()) +// .args(cache::args::global_args()) +// .args(output::args::global_args()) +// .subcommand(folder::args::subcmd()) +// .subcommand(envelope::args::subcmd()) +// .subcommand(flag::args::subcmd()) +// .subcommand(message::args::subcmd()) +// .subcommand(template::args::subcmd()) +// } + +// #[tokio::main] +// async fn main() -> Result<()> { +// #[cfg(not(target_os = "windows"))] +// if let Err((_, err)) = coredump::register_panic_handler() { +// warn!("cannot register custom panic handler: {err}"); +// debug!("cannot register custom panic handler: {err:?}"); +// } + +// let default_env_filter = env_logger::DEFAULT_FILTER_ENV; +// env_logger::init_from_env(env_logger::Env::default().filter_or(default_env_filter, "off")); + +// // check mailto command before app initialization +// let raw_args: Vec = env::args().collect(); +// if raw_args.len() > 1 && raw_args[1].starts_with("mailto:") { +// let url = Url::parse(&raw_args[1])?; +// let (toml_account_config, account_config) = TomlConfig::from_default_paths() +// .await? +// .into_account_configs(None, false)?; +// let backend_builder = +// BackendBuilder::new(toml_account_config, account_config.clone(), true).await?; +// let backend = backend_builder.build().await?; +// let mut printer = StdoutPrinter::default(); + +// return message::handlers::mailto(&account_config, &backend, &mut printer, &url).await; +// } + +// let app = _create_app(); +// let m = app.get_matches(); + +// let some_config_path = config::args::parse_global_arg(&m); +// let some_account_name = account::command::parse_global_arg(&m); +// let disable_cache = cache::args::parse_disable_cache_arg(&m); +// let folder = folder::args::parse_global_source_arg(&m); + +// let toml_config = TomlConfig::from_some_path_or_default(some_config_path).await?; + +// let mut printer = StdoutPrinter::try_from(&m)?; + +// #[cfg(feature = "imap")] +// if let BackendConfig::Imap(imap_config) = &account_config.backend { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// match imap::args::matches(&m)? { +// Some(imap::args::Cmd::Notify(keepalive)) => { +// let backend = +// ImapBackend::new(account_config.clone(), imap_config.clone(), None).await?; +// imap::handlers::notify(&mut backend, &folder, keepalive).await?; +// return Ok(()); +// } +// Some(imap::args::Cmd::Watch(keepalive)) => { +// let backend = +// ImapBackend::new(account_config.clone(), imap_config.clone(), None).await?; +// imap::handlers::watch(&mut backend, &folder, keepalive).await?; +// return Ok(()); +// } +// _ => (), +// } +// } + +// let (toml_account_config, account_config) = toml_config +// .clone() +// .into_account_configs(some_account_name, disable_cache)?; + +// // checks folder commands +// match folder::args::matches(&m)? { +// Some(folder::args::Cmd::Create) => { +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// let folder = folder +// .ok_or_else(|| anyhow!("the folder argument is missing")) +// .context("cannot create folder")?; +// return folder::handlers::create(&mut printer, &backend, &folder).await; +// } +// Some(folder::args::Cmd::List(max_width)) => { +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return folder::handlers::list(&account_config, &mut printer, &backend, max_width) +// .await; +// } +// Some(folder::args::Cmd::Expunge) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return folder::handlers::expunge(&mut printer, &backend, &folder).await; +// } +// Some(folder::args::Cmd::Delete) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return folder::handlers::delete(&mut printer, &backend, &folder).await; +// } +// _ => (), +// } + +// match envelope::args::matches(&m)? { +// Some(envelope::args::Cmd::List(max_width, page_size, page)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return envelope::handlers::list( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// max_width, +// page_size, +// page, +// ) +// .await; +// } +// _ => (), +// } + +// match flag::args::matches(&m)? { +// Some(flag::args::Cmd::Set(ids, ref flags)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return flag::handlers::set(&mut printer, &backend, &folder, ids, flags).await; +// } +// Some(flag::args::Cmd::Add(ids, ref flags)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return flag::handlers::add(&mut printer, &backend, &folder, ids, flags).await; +// } +// Some(flag::args::Cmd::Remove(ids, ref flags)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return flag::handlers::remove(&mut printer, &backend, &folder, ids, flags).await; +// } +// _ => (), +// } + +// match message::args::matches(&m)? { +// Some(message::args::Cmd::Attachments(ids)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return message::handlers::attachments( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// ids, +// ) +// .await; +// } +// Some(message::args::Cmd::Copy(ids, to_folder)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return message::handlers::copy(&mut printer, &backend, &folder, to_folder, ids).await; +// } +// Some(message::args::Cmd::Delete(ids)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return message::handlers::delete(&mut printer, &backend, &folder, ids).await; +// } +// Some(message::args::Cmd::Forward(id, headers, body)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; +// return message::handlers::forward( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// id, +// headers, +// body, +// ) +// .await; +// } +// Some(message::args::Cmd::Move(ids, to_folder)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return message::handlers::move_(&mut printer, &backend, &folder, to_folder, ids).await; +// } +// Some(message::args::Cmd::Read(ids, text_mime, raw, headers)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return message::handlers::read( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// ids, +// text_mime, +// raw, +// headers, +// ) +// .await; +// } +// Some(message::args::Cmd::Reply(id, all, headers, body)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; +// return message::handlers::reply( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// id, +// all, +// headers, +// body, +// ) +// .await; +// } +// Some(message::args::Cmd::Save(raw_email)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return message::handlers::save(&mut printer, &backend, &folder, raw_email).await; +// } +// Some(message::args::Cmd::Send(raw_email)) => { +// let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; +// return message::handlers::send(&account_config, &mut printer, &backend, raw_email) +// .await; +// } +// Some(message::args::Cmd::Write(headers, body)) => { +// let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; +// return message::handlers::write( +// &account_config, +// &mut printer, +// &backend, +// headers, +// body, +// ) +// .await; +// } +// _ => (), +// } + +// match template::args::matches(&m)? { +// Some(template::args::Cmd::Forward(id, headers, body)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return template::handlers::forward( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// id, +// headers, +// body, +// ) +// .await; +// } +// Some(template::args::Cmd::Write(headers, body)) => { +// return template::handlers::write(&account_config, &mut printer, headers, body).await; +// } +// Some(template::args::Cmd::Reply(id, all, headers, body)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return template::handlers::reply( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// id, +// all, +// headers, +// body, +// ) +// .await; +// } +// Some(template::args::Cmd::Save(template)) => { +// let folder = folder.unwrap_or(DEFAULT_INBOX_FOLDER); +// let backend = Backend::new(toml_account_config, account_config.clone(), false).await?; +// return template::handlers::save( +// &account_config, +// &mut printer, +// &backend, +// &folder, +// template, +// ) +// .await; +// } +// Some(template::args::Cmd::Send(template)) => { +// let backend = Backend::new(toml_account_config, account_config.clone(), true).await?; +// return template::handlers::send(&account_config, &mut printer, &backend, template) +// .await; +// } +// _ => (), +// } + +// Ok(()) +// } diff --git a/src/man/command.rs b/src/man/command.rs index 7316b8e..85d17a3 100644 --- a/src/man/command.rs +++ b/src/man/command.rs @@ -1,16 +1,58 @@ use anyhow::Result; -use clap::Parser; +use clap::{CommandFactory, Parser}; +use clap_mangen::Man; +use log::info; use shellexpand_utils::{canonicalize, expand}; -use std::path::PathBuf; +use std::{fs, path::PathBuf}; + +use crate::{cli::Cli, printer::Printer}; /// Generate all man pages to the given directory #[derive(Debug, Parser)] -pub struct Generate { +pub struct Command { /// Directory where man files should be generated in #[arg(value_parser = dir_parser)] pub dir: PathBuf, } +impl Command { + pub async fn execute(self, printer: &mut impl Printer) -> Result<()> { + info!("executing man generate command"); + + let cmd = Cli::command(); + let cmd_name = cmd.get_name().to_string(); + let subcmds = cmd.get_subcommands().cloned().collect::>(); + let subcmds_len = subcmds.len() + 1; + + let mut buffer = Vec::new(); + Man::new(cmd).render(&mut buffer)?; + + fs::create_dir_all(&self.dir)?; + printer.print_log(format!("Generating man page for command {cmd_name}…"))?; + fs::write(self.dir.join(format!("{}.1", cmd_name)), buffer)?; + + for subcmd in subcmds { + let subcmd_name = subcmd.get_name().to_string(); + + let mut buffer = Vec::new(); + Man::new(subcmd).render(&mut buffer)?; + + printer.print_log(format!("Generating man page for subcommand {subcmd_name}…"))?; + fs::write( + self.dir.join(format!("{}-{}.1", cmd_name, subcmd_name)), + buffer, + )?; + } + + printer.print(format!( + "{subcmds_len} man page(s) successfully generated in {:?}!", + self.dir + ))?; + + Ok(()) + } +} + /// Parse the given [`str`] as [`PathBuf`]. /// /// The path is first shell expanded, then canonicalized (if diff --git a/src/man/handler.rs b/src/man/handler.rs deleted file mode 100644 index 069e45e..0000000 --- a/src/man/handler.rs +++ /dev/null @@ -1,35 +0,0 @@ -use anyhow::Result; -use clap::Command; -use clap_mangen::Man; -use std::{fs, path::PathBuf}; - -use crate::printer::Printer; - -pub fn generate(printer: &mut impl Printer, cmd: Command, dir: PathBuf) -> Result<()> { - let cmd_name = cmd.get_name().to_string(); - let subcmds = cmd.get_subcommands().cloned().collect::>(); - let subcmds_len = subcmds.len() + 1; - - let mut buffer = Vec::new(); - Man::new(cmd).render(&mut buffer)?; - - fs::create_dir_all(&dir)?; - printer.print_log(format!("Generating man page for command {cmd_name}…"))?; - fs::write(dir.join(format!("{}.1", cmd_name)), buffer)?; - - for subcmd in subcmds { - let subcmd_name = subcmd.get_name().to_string(); - - let mut buffer = Vec::new(); - Man::new(subcmd).render(&mut buffer)?; - - printer.print_log(format!("Generating man page for subcommand {subcmd_name}…"))?; - fs::write(dir.join(format!("{}-{}.1", cmd_name, subcmd_name)), buffer)?; - } - - printer.print(format!( - "Successfully generated {subcmds_len} man page(s) in {dir:?}!" - ))?; - - Ok(()) -} diff --git a/src/man/mod.rs b/src/man/mod.rs index a2f4939..9fe7961 100644 --- a/src/man/mod.rs +++ b/src/man/mod.rs @@ -1,2 +1 @@ pub mod command; -pub mod handler; diff --git a/src/output/output.rs b/src/output/output.rs index 9b1d19b..278c59d 100644 --- a/src/output/output.rs +++ b/src/output/output.rs @@ -1,22 +1,18 @@ use anyhow::{anyhow, Error, Result}; use atty::Stream; +use clap::ValueEnum; use serde::Serialize; use std::{fmt, str::FromStr}; use termcolor::ColorChoice; /// Represents the available output formats. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] pub enum OutputFmt { + #[default] Plain, Json, } -impl Default for OutputFmt { - fn default() -> Self { - Self::Plain - } -} - impl FromStr for OutputFmt { type Err = Error; @@ -52,20 +48,15 @@ impl OutputJson { } /// Represent the available color configs. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, ValueEnum)] pub enum ColorFmt { Never, Always, Ansi, + #[default] Auto, } -impl Default for ColorFmt { - fn default() -> Self { - Self::Auto - } -} - impl FromStr for ColorFmt { type Err = Error;