From 475860c946d0b172d605a4c8cb58a2c0651fa0d8 Mon Sep 17 00:00:00 2001 From: Manos Pitsidianakis Date: Sat, 13 Jul 2024 16:00:34 +0300 Subject: [PATCH] meli/subcommands: accept - for stdio in {create,test}_config Accept "-" operand in create_config and test_config subcommands as stdout and stdin respectively. Signed-off-by: Manos Pitsidianakis --- meli/docs/meli.1 | 20 ++++- meli/src/args.rs | 33 +++++-- meli/src/conf.rs | 133 ++++++++++++++++++++++++++++- meli/src/subcommands.rs | 56 ++++++------ meli/src/terminal.rs | 5 ++ meli/tests/test_cli_subcommands.rs | 33 +++++++ 6 files changed, 242 insertions(+), 38 deletions(-) diff --git a/meli/docs/meli.1 b/meli/docs/meli.1 index dea8c36e..f50e03ee 100644 --- a/meli/docs/meli.1 +++ b/meli/docs/meli.1 @@ -69,9 +69,25 @@ Start meli with given configuration file. Create configuration file in .Pa path if given, or at -.Pa $XDG_CONFIG_HOME/meli/config.toml +.Pa $XDG_CONFIG_HOME/meli/config.toml Ns +\&. +If +.Ar path +is +.Ar \- +the result is printed to the standard output stream. .It Cm test-config Op Ar path -Test a configuration file for syntax issues or missing options. +Test a configuration for syntax issues or missing options. +The configuration is read from +.Pa path +if given, or from +.Pa $XDG_CONFIG_HOME/meli/config.toml Ns +\&. +If +.Ar path +is +.Ar \- +the configuration is read from the standard input stream. .It Cm man Op Ar page Print documentation page and exit (Piping to a pager is recommended). .It Cm install-man Op Ar path diff --git a/meli/src/args.rs b/meli/src/args.rs index a183268b..eccd7328 100644 --- a/meli/src/args.rs +++ b/meli/src/args.rs @@ -21,10 +21,29 @@ //! Command line arguments. +use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + use super::*; #[cfg(feature = "cli-docs")] use crate::manpages; +fn try_path_or_stdio(input: &OsStr) -> PathOrStdio { + if input.as_bytes() == b"-" { + PathOrStdio::Stdio + } else { + PathOrStdio::Path(PathBuf::from(input)) + } +} + +/// `Pathbuf` or standard stream (`-` operand). +#[derive(Debug)] +pub enum PathOrStdio { + /// Path + Path(PathBuf), + /// standard stream (`-` operand) + Stdio, +} + #[derive(Debug, StructOpt)] #[structopt(name = "meli", about = "terminal mail client", version_short = "v")] pub struct Opt { @@ -51,17 +70,21 @@ pub enum SubCommand { EditConfig, /// create a sample configuration file with available configuration options. /// If `PATH` is not specified, meli will try to create it in - /// `$XDG_CONFIG_HOME/meli/config.toml` + /// `$XDG_CONFIG_HOME/meli/config.toml`. Path `-` will output to standard + /// output instead. #[structopt(display_order = 1)] CreateConfig { - #[structopt(value_name = "NEW_CONFIG_PATH", parse(from_os_str))] - path: Option, + #[structopt(value_name = "NEW_CONFIG_PATH", parse(from_os_str = try_path_or_stdio))] + path: Option, }, /// test a configuration file for syntax issues or missing options. + /// If `PATH` is not specified, meli will try to read it from + /// `$XDG_CONFIG_HOME/meli/config.toml`. Path `-` will read input from + /// standard input instead. #[structopt(display_order = 2)] TestConfig { - #[structopt(value_name = "CONFIG_PATH", parse(from_os_str))] - path: Option, + #[structopt(value_name = "CONFIG_PATH", parse(from_os_str = try_path_or_stdio))] + path: Option, }, #[structopt(display_order = 3)] /// Testing tools such as IMAP, SMTP shells for debugging. diff --git a/meli/src/conf.rs b/meli/src/conf.rs index fccadb92..065a74e5 100644 --- a/meli/src/conf.rs +++ b/meli/src/conf.rs @@ -428,6 +428,8 @@ define(`include', `builtin_include(substr($1,1,decr(decr(len($1)))))dnl')dnl } impl FileSettings { + pub const EXAMPLE_CONFIG: &'static str = include_str!("../docs/samples/sample-config.toml"); + pub fn new() -> Result { let config_path = get_config_file()?; if !config_path.exists() { @@ -462,6 +464,131 @@ impl FileSettings { Self::validate(config_path, true, false) } + /// Validate configuration from `input` string. + pub fn validate_string(s: String, interactive: bool, clear_extras: bool) -> Result { + let map: toml::value::Table = toml::from_str(&s).map_err(|err| { + Error::new("Config file is invalid TOML") + .set_source(Some(Arc::new(err))) + .set_kind(ErrorKind::ValueError) + })?; + + // Check that a global composing option is set and return a user-friendly + // error message because the default serde one is confusing. + if !map.contains_key("composing") { + let err_msg = r#"You must set a global `composing` option. If you override `composing` in each account, you can use a dummy global like follows: + +[composing] +send_mail = 'false' + +This is required so that you don't accidentally start meli and find out later that you can't send emails."#; + if interactive { + println!("{}", err_msg); + let ask = Ask { + message: "Would you like to append this dummy value in your configuration \ + input and continue?" + .to_string(), + }; + if ask.run() { + let mut s = s; + s.push_str("[composing]\nsend_mail = 'false'\n"); + return Self::validate_string(s, interactive, clear_extras); + } + } + return Err(Error::new(err_msg).set_kind(ErrorKind::Configuration)); + } + let mut s: Self = toml::from_str(&s).map_err(|err| { + Error::new("Input contains errors") + .set_source(Some(Arc::new(err))) + .set_kind(ErrorKind::Configuration) + })?; + let backends = melib::backends::Backends::new(); + let Themes { + light: default_light, + dark: default_dark, + .. + } = Themes::default(); + for (k, v) in default_light.keys.into_iter() { + if !s.terminal.themes.light.contains_key(&k) { + s.terminal.themes.light.insert(k, v); + } + } + for theme in s.terminal.themes.other_themes.values_mut() { + for (k, v) in default_dark.keys.clone().into_iter() { + if !theme.contains_key(&k) { + theme.insert(k, v); + } + } + } + for (k, v) in default_dark.keys.into_iter() { + if !s.terminal.themes.dark.contains_key(&k) { + s.terminal.themes.dark.insert(k, v); + } + } + match s.terminal.theme.as_str() { + themes::DARK | themes::LIGHT => {} + t if s.terminal.themes.other_themes.contains_key(t) => {} + t => { + return Err(Error::new(format!("Theme `{}` was not found.", t)) + .set_kind(ErrorKind::Configuration)); + } + } + + s.terminal.themes.validate()?; + for (name, acc) in s.accounts.iter_mut() { + let FileAccount { + root_mailbox, + format, + identity, + extra_identities, + read_only, + display_name, + order, + subscribed_mailboxes, + mailboxes, + extra, + manual_refresh, + default_mailbox: _, + refresh_command: _, + search_backend: _, + conf_override: _, + } = acc.clone(); + + let lowercase_format = format.to_lowercase(); + let mut s = AccountSettings { + name: name.to_string(), + root_mailbox, + format: format.clone(), + identity, + extra_identities, + read_only, + display_name, + order, + subscribed_mailboxes, + manual_refresh, + mailboxes: mailboxes + .into_iter() + .map(|(k, v)| (k, v.mailbox_conf)) + .collect(), + extra: extra.into_iter().collect(), + }; + s.validate_config()?; + backends.validate_config(&lowercase_format, &mut s)?; + if !s.extra.is_empty() { + return Err(Error::new(format!( + "Unrecognised configuration values: {:?}", + s.extra + )) + .set_kind(ErrorKind::Configuration)); + } + if clear_extras { + acc.extra.clear(); + } + } + + Ok(s) + } + + /// Validate `path` and print errors. pub fn validate(path: PathBuf, interactive: bool, clear_extras: bool) -> Result { let s = pp::pp(&path)?; let map: toml::value::Table = toml::from_str(&s).map_err(|err| { @@ -880,7 +1007,7 @@ pub fn create_config_file(p: &Path) -> Result<()> { .create_new(true) .open(p) .chain_err_summary(|| format!("Cannot create configuration file in {}", p.display()))?; - file.write_all(include_bytes!("../docs/samples/sample-config.toml")) + file.write_all(FileSettings::EXAMPLE_CONFIG.as_bytes()) .and_then(|()| file.flush()) .chain_err_summary(|| format!("Could not write to configuration file {}", p.display()))?; println!("Written example configuration to {}", p.display()); @@ -1407,8 +1534,6 @@ server_password_command = "false" send_mail = 'false' "#; - const EXAMPLE_CONFIG: &str = include_str!("../docs/samples/sample-config.toml"); - #[test] fn test_config_parse() { let tempdir = tempfile::tempdir().unwrap(); @@ -1446,7 +1571,7 @@ send_mail = 'false' /* Test sample config */ - let example_config = EXAMPLE_CONFIG.replace("\n#", "\n"); + let example_config = FileSettings::EXAMPLE_CONFIG.replace("\n#", "\n"); let re = regex::Regex::new(r#"root_mailbox\s*=\s*"[^"]*""#).unwrap(); let example_config = re.replace_all( &example_config, diff --git a/meli/src/subcommands.rs b/meli/src/subcommands.rs index 0f7bf199..65f4efb4 100644 --- a/meli/src/subcommands.rs +++ b/meli/src/subcommands.rs @@ -29,13 +29,16 @@ use std::{ use crossbeam::channel::{Receiver, Sender}; use melib::{Result, ShellExpandTrait}; -use crate::*; +use crate::{args::PathOrStdio, *}; -pub fn create_config(path: Option) -> Result<()> { - let config_path = if let Some(path) = path { - path.expand() - } else { - conf::get_config_file()? +pub fn create_config(path: Option) -> Result<()> { + let config_path = match path { + Some(PathOrStdio::Stdio) => { + std::io::stdout().write_all(conf::FileSettings::EXAMPLE_CONFIG.as_bytes())?; + return Ok(()); + } + Some(PathOrStdio::Path(path)) => path.expand(), + None => conf::get_config_file()?, }; if config_path.exists() { return Err(Error::new(format!( @@ -53,12 +56,12 @@ pub fn edit_config() -> Result<()> { .map_err(|err| { format!("Could not find any value in environment variables EDITOR and VISUAL. {err}") })?; - let config_path = crate::conf::get_config_file()?; + let config_path = conf::get_config_file()?; let mut cmd = Command::new(editor); let mut handle = &mut cmd; - for c in crate::conf::get_included_configs(config_path)? { + for c in conf::get_included_configs(config_path)? { handle = handle.arg(&c); } let mut handle = handle @@ -76,17 +79,8 @@ pub fn man(page: manpages::ManPages, source: bool) -> Result { } #[cfg(feature = "cli-docs")] -pub fn pager(v: String, no_raw: Option>) -> Result<()> { - if let Some(no_raw) = no_raw { - match no_raw { - Some(true) => {} - None if (unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }) => {} - Some(false) | None => { - println!("{v}"); - return Ok(()); - } - } - } else if unsafe { libc::isatty(libc::STDOUT_FILENO) != 1 } { +pub fn pager(v: String, no_raw: bool) -> Result<()> { + if no_raw || !terminal::is_tty() { println!("{v}"); return Ok(()); } @@ -105,7 +99,7 @@ pub fn pager(v: String, no_raw: Option>) -> Result<()> { } #[cfg(not(feature = "cli-docs"))] -pub fn man(_: crate::args::ManOpt) -> Result<()> { +pub fn man(_: args::ManOpt) -> Result<()> { Err(Error::new("error: this version of meli was not build with embedded documentation (cargo feature `cli-docs`). You might have it installed as manpages (eg `man meli`), otherwise check https://meli-email.org")) } @@ -129,11 +123,19 @@ pub fn compiled_with() -> Result<()> { Ok(()) } -pub fn test_config(path: Option) -> Result<()> { - let config_path = if let Some(path) = path { - path.expand() - } else { - crate::conf::get_config_file()? +pub fn test_config(path: Option) -> Result<()> { + let config_path = match path { + Some(PathOrStdio::Stdio) => { + let mut input = String::new(); + std::io::stdin().read_to_string(&mut input)?; + if input.trim().is_empty() { + return Err(Error::new("Input was empty.").set_kind(ErrorKind::ValueError)); + } + conf::FileSettings::validate_string(input, true, false)?; + return Ok(()); + } + Some(PathOrStdio::Path(path)) => path.expand(), + None => conf::get_config_file()?, }; conf::FileSettings::validate(config_path, true, false)?; Ok(()) @@ -173,7 +175,7 @@ pub fn view( Ok(state) } -pub fn tool(path: Option, opt: crate::args::ToolOpt) -> Result<()> { +pub fn tool(path: Option, opt: args::ToolOpt) -> Result<()> { use melib::utils::futures::timeout; use crate::{args::ToolOpt, conf::composing::SendMail}; @@ -181,7 +183,7 @@ pub fn tool(path: Option, opt: crate::args::ToolOpt) -> Result<()> { let config_path = if let Some(path) = path { path.expand() } else { - crate::conf::get_config_file()? + conf::get_config_file()? }; let conf = conf::FileSettings::validate(config_path, true, false)?; let account = match opt { diff --git a/meli/src/terminal.rs b/meli/src/terminal.rs index 9d79053e..2313c08f 100644 --- a/meli/src/terminal.rs +++ b/meli/src/terminal.rs @@ -260,3 +260,8 @@ impl TextPresentation for str { Cow::from(self) } } + +/// Returns `true` if standard output corresponds to an interactive TTY session. +pub fn is_tty() -> bool { + unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 } +} diff --git a/meli/tests/test_cli_subcommands.rs b/meli/tests/test_cli_subcommands.rs index 1357c236..a3685a1b 100644 --- a/meli/tests/test_cli_subcommands.rs +++ b/meli/tests/test_cli_subcommands.rs @@ -114,6 +114,38 @@ fn test_cli_subcommands() { } } + fn test_subcommand_config_stdio() { + { + let mut cmd = Command::cargo_bin("meli").unwrap(); + let output = cmd.arg("create-config").arg("-").output().unwrap().assert(); + output.code(0).stdout(predicates::str::is_empty().not()); + } + { + let mut cmd = Command::cargo_bin("meli").unwrap(); + let output = cmd + .arg("test-config") + .arg("-") + .write_stdin( + br#" +[accounts.imap] +root_mailbox = "INBOX" +format = "imap" +identity="username@example.com" +server_username = "null" +server_hostname = "example.com" +server_password_command = "false" + +[composing] +send_mail = 'false' + "#, + ) + .output() + .unwrap() + .assert(); + output.code(0).stdout(predicates::str::is_empty()); + } + } + fn test_subcommand_man() { for (man, title) in [ ("meli.1", "MELI(1)"), @@ -176,6 +208,7 @@ fn test_cli_subcommands() { test_subcommand_succeeds("compiled-with"); test_subcommand_succeeds("man"); test_subcommand_man(); + test_subcommand_config_stdio(); let tmp_dir = TempDir::new().unwrap();