use std::{
fmt,
fs::{self, OpenOptions},
io::{IsTerminal, Write, stdin, stdout},
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use clap::Parser;
use pimalaya_cli::{printer::Printer, prompt};
use pimalaya_config::toml::TomlConfig;
use schemars::JsonSchema;
use serde::Serialize;
use crate::{
config::Config,
wizard::discover::{self, CONFIG_SAMPLE_URL},
};
#[derive(Debug, Parser)]
pub struct ConfigureCommand;
impl ConfigureCommand {
pub fn execute(self, printer: &mut impl Printer, config_paths: &[PathBuf]) -> Result<()> {
if !stdin().is_terminal() {
bail!(
"Configuring needs a terminal to prompt on, \
write the configuration by hand instead: {CONFIG_SAMPLE_URL}"
);
}
let path = Config::target_path(config_paths)?;
let existing = ExistingConfig::read(&path)?;
let (base_name, mut account) = discover::run()?;
let name = account_name(&base_name, existing.as_ref());
let default = !existing.as_ref().is_some_and(|config| config.has_default);
account.default = default;
let config = GeneratedConfig {
document: account.render(&name)?,
name,
default,
};
if printer.is_json() || !stdout().is_terminal() {
return printer.out(config);
}
match existing {
Some(_) => append_or_print(printer, &path, config),
None => save_or_print(printer, &path, config),
}
}
}
struct ExistingConfig {
names: Vec<String>,
has_default: bool,
}
impl ExistingConfig {
fn read(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let config = Config::from_paths(&[path.to_path_buf()])
.with_context(|| format!("Read the configuration at {}", path.display()))?;
Ok(Some(Self {
names: config.accounts.keys().cloned().collect(),
has_default: config.accounts.values().any(|account| account.default),
}))
}
}
#[derive(Serialize, JsonSchema)]
pub struct GeneratedConfig {
name: String,
default: bool,
document: String,
}
impl fmt::Display for GeneratedConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}", self.document.trim_end())
}
}
pub fn print_welcome(path: &Path) {
eprintln!();
eprintln!("Welcome to Himalaya, the CLI to manage emails.");
eprintln!();
eprintln!("Himalaya talks to your existing mailbox over IMAP, JMAP, Gmail, Microsoft");
eprintln!("Graph or a local Maildir. It needs one account to know which mailbox to");
eprintln!("read, and no configuration file was found at:");
eprintln!();
eprintln!(" {}", path.display());
eprintln!();
eprintln!("The wizard discovers a provider's settings from your email address, tests");
eprintln!("the connection and generates a ready-to-use account. Everything discovery");
eprintln!("does not cover is written by hand, and every field is documented at:");
eprintln!();
eprintln!(" {CONFIG_SAMPLE_URL}");
eprintln!();
eprintln!("At anytime, you can create a new account with the command:");
eprintln!();
eprintln!(" himalaya configure");
eprintln!();
}
fn account_name(base: &str, existing: Option<&ExistingConfig>) -> String {
let taken = existing
.map(|config| config.names.as_slice())
.unwrap_or(&[]);
if !taken.iter().any(|name| name == base) {
return base.to_string();
}
let mut suffix = 2;
loop {
let name = format!("{base}-{suffix}");
if !taken.contains(&name) {
return name;
}
suffix += 1;
}
}
fn save_or_print(printer: &mut impl Printer, path: &Path, config: GeneratedConfig) -> Result<()> {
let prompt = format!("Save this account to {}?", path.display());
if !prompt::bool(prompt, true)? {
return printer.out(config);
}
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("Create the config directory {}", parent.display()))?;
}
fs::write(path, config.to_string())
.with_context(|| format!("Write the config file {}", path.display()))?;
print_saved(path, &config);
Ok(())
}
fn append_or_print(printer: &mut impl Printer, path: &Path, config: GeneratedConfig) -> Result<()> {
let prompt = format!("Append account `{}` to {}?", config.name, path.display());
if !prompt::bool(prompt, true)? {
return printer.out(config);
}
let mut file = OpenOptions::new()
.append(true)
.open(path)
.with_context(|| format!("Open the config file {}", path.display()))?;
write!(file, "\n{config}")
.with_context(|| format!("Append to the config file {}", path.display()))?;
print_saved(path, &config);
Ok(())
}
fn print_saved(path: &Path, config: &GeneratedConfig) {
let name = &config.name;
eprintln!();
eprintln!("Account `{name}` saved to {}.", path.display());
if !config.default {
eprintln!("Another account holds the default, so name this one with `-a {name}`.");
}
eprintln!("Run `himalaya envelope list` to read your mailbox.");
}
#[cfg(test)]
mod tests {
use std::{
env, fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use super::*;
static NEXT_CONFIG: AtomicUsize = AtomicUsize::new(0);
fn config_path() -> PathBuf {
let id = NEXT_CONFIG.fetch_add(1, Ordering::Relaxed);
env::temp_dir().join(format!("himalaya-configure-{id}.toml"))
}
fn account(default: bool) -> crate::config::AccountConfig {
crate::config::AccountConfig {
default,
maildir: Some(crate::config::MaildirConfig {
root: PathBuf::from("/tmp/mail"),
keywords: Default::default(),
}),
..Default::default()
}
}
#[test]
fn a_generated_account_parses_back() {
let document = account(true).render("perso").expect("render the account");
let config: Config = toml::from_str(&document).expect("parse the generated config");
let account = &config.accounts["perso"];
assert_eq!(config.accounts.len(), 1);
assert!(account.default);
assert_eq!(
account.maildir.as_ref().map(|c| c.root.as_path()),
Some(Path::new("/tmp/mail"))
);
assert!(!document.contains("imap"));
assert!(!document.contains("table"));
let lines: Vec<&str> = document.lines().collect();
assert_eq!(lines[0], "[accounts.perso]");
assert_eq!(lines[1], "default = true");
}
#[test]
fn the_endpoint_reads_before_its_credentials() {
let document = account(true).render("perso").expect("render the account");
let maildir: Vec<&str> = document
.lines()
.filter(|line| line.starts_with("maildir."))
.collect();
assert_eq!(maildir, ["maildir.root = \"/tmp/mail\""]);
}
#[test]
fn an_appended_account_keeps_the_existing_one() {
let path = config_path();
fs::write(
&path,
"# my accounts\n[accounts.work]\ndefault = true\nmaildir.root = \"/tmp/work\"",
)
.expect("write the existing config");
let existing = ExistingConfig::read(&path)
.expect("read the existing config")
.expect("an existing config");
assert_eq!(existing.names, ["work"]);
assert!(existing.has_default);
let document = account(!existing.has_default)
.render("perso")
.expect("render the account");
let mut file = OpenOptions::new().append(true).open(&path).expect("open");
write!(file, "\n{document}").expect("append the generated account");
drop(file);
let content = fs::read_to_string(&path).expect("read back");
let config: Config = toml::from_str(&content).expect("parse the appended config");
assert_eq!(config.accounts.len(), 2);
let defaults = config
.accounts
.values()
.filter(|account| account.default)
.count();
assert_eq!(defaults, 1);
assert!(config.accounts["work"].default);
assert!(content.starts_with("# my accounts"));
fs::remove_file(&path).expect("remove the config");
}
#[test]
fn a_taken_name_gets_a_suffix() {
let existing = ExistingConfig {
names: vec!["perso".to_string(), "perso-2".to_string()],
has_default: true,
};
assert_eq!(account_name("perso", None), "perso");
assert_eq!(account_name("perso", Some(&existing)), "perso-3");
assert_eq!(account_name("work", Some(&existing)), "work");
}
#[test]
fn a_missing_configuration_constrains_nothing() {
let existing = ExistingConfig::read(&config_path()).expect("read a missing config");
assert!(existing.is_none());
}
}