use crate::config::{ColorRule, Config};
use crate::muttrc::{assignments, convert_color, is_yes, split_from, tokenize};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Menu {
Index,
Pager,
Generic,
}
impl Menu {
fn parse(name: &str) -> Option<Menu> {
match name {
"index" => Some(Menu::Index),
"pager" => Some(Menu::Pager),
"generic" => Some(Menu::Generic),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Menu::Index => "index",
Menu::Pager => "pager",
Menu::Generic => "generic",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Set {
name: String,
value: String,
},
Unset(String),
Toggle(String),
Query(String),
Bind {
menu: Menu,
key: String,
function: String,
},
Macro {
menu: Menu,
key: String,
seq: String,
},
Color {
object: String,
fg: String,
bg: String,
pattern: Option<String>,
},
Ignore(Vec<String>),
Unignore(Vec<String>),
Uncolor {
object: String,
pattern: Option<String>,
},
Unhook(String),
Unmailboxes(Vec<String>),
Unalias(Vec<String>),
Reset(String),
Alternates(Vec<String>),
Unalternates(Vec<String>),
AutoView(Vec<String>),
UnAutoView(Vec<String>),
AlternativeOrder(Vec<String>),
UnAlternativeOrder(Vec<String>),
MyHdr(String),
UnMyHdr(Vec<String>),
Alias {
nick: String,
expansion: String,
},
Push(String),
Exec(String),
}
pub fn parse(line: &str) -> Result<Vec<Command>, String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return Ok(Vec::new());
}
let tokens = tokenize(trimmed);
let Some(cmd) = tokens.first() else {
return Ok(Vec::new());
};
let args = &tokens[1..];
match cmd.as_str() {
"set" => {
if args.is_empty() {
return Err("set what?".into());
}
Ok(assignments(args).into_iter().map(one_set).collect())
}
"unset" => want(args, "unset what?").map(|names| {
names
.iter()
.map(|n| Command::Unset(n.to_string()))
.collect()
}),
"reset" => want(args, "reset what?").map(|names| {
names
.iter()
.map(|n| Command::Reset(n.to_string()))
.collect()
}),
"uncolor" | "unmono" => {
let Some(object) = args.first() else {
return Err(format!("{cmd} OBJECT [PATTERN|*]"));
};
Ok(vec![Command::Uncolor {
object: object.clone(),
pattern: (args.len() > 1).then(|| args[1..].join(" ")),
}])
}
"mono" => {
let (Some(object), Some(attr)) = (args.first(), args.get(1)) else {
return Err("mono OBJECT ATTRIBUTE [PATTERN]".into());
};
if !matches!(
attr.as_str(),
"none" | "bold" | "underline" | "reverse" | "standout"
) {
return Err(format!("mono: no such attribute {attr:?}"));
}
Ok(vec![Command::Color {
object: object.clone(),
fg: attr.clone(),
bg: "default".into(),
pattern: (args.len() > 2).then(|| args[2..].join(" ")),
}])
}
"unhook" => want(args, "unhook TYPE|*").map(|types| {
types
.iter()
.map(|t| Command::Unhook(t.to_string()))
.collect()
}),
"unmailboxes" => {
want(args, "unmailboxes MAILBOX...|*").map(|m| vec![Command::Unmailboxes(m)])
}
"unalias" => want(args, "unalias NICK...|*").map(|n| vec![Command::Unalias(n)]),
"toggle" => want(args, "toggle what?").map(|names| {
names
.iter()
.map(|n| Command::Toggle(n.to_string()))
.collect()
}),
"bind" | "macro" => {
let (Some(menus), Some(key)) = (args.first(), args.get(1)) else {
return Err(format!("{cmd} MENU KEY ..."));
};
let Some(rest) = args.get(2) else {
return Err(if cmd == "bind" {
"bind MENU KEY FUNCTION".into()
} else {
"macro MENU KEY SEQUENCE".into()
});
};
let mut out = Vec::new();
for name in menus.split(',') {
let Some(menu) = Menu::parse(name) else {
return Err(format!("unknown menu {name:?}"));
};
out.push(if cmd == "bind" {
Command::Bind {
menu,
key: key.clone(),
function: rest.clone(),
}
} else {
Command::Macro {
menu,
key: key.clone(),
seq: rest.clone(),
}
});
}
Ok(out)
}
"color" => {
let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2))
else {
return Err("color OBJECT FG BG [PATTERN]".into());
};
Ok(vec![Command::Color {
object: object.clone(),
fg: fg.clone(),
bg: bg.clone(),
pattern: (args.len() > 3).then(|| args[3..].join(" ")),
}])
}
"ignore" => want(args, "ignore which headers?").map(|h| vec![Command::Ignore(h)]),
"unignore" => want(args, "unignore which headers?").map(|h| vec![Command::Unignore(h)]),
"alternates" => want(args, "alternates PATTERN...").map(|p| vec![Command::Alternates(p)]),
"unalternates" => {
want(args, "unalternates PATTERN...").map(|p| vec![Command::Unalternates(p)])
}
"auto_view" => want(args, "auto_view MIMETYPE...").map(|t| vec![Command::AutoView(t)]),
"unauto_view" => {
want(args, "unauto_view MIMETYPE...").map(|t| vec![Command::UnAutoView(t)])
}
"alternative_order" => {
want(args, "alternative_order MIMETYPE...").map(|t| vec![Command::AlternativeOrder(t)])
}
"unalternative_order" => want(args, "unalternative_order MIMETYPE...")
.map(|t| vec![Command::UnAlternativeOrder(t)]),
"my_hdr" => {
let rest = trimmed["my_hdr".len()..].trim().trim_matches('"');
if !rest.contains(':') || rest.starts_with(':') {
return Err("my_hdr \"Name: value\"".into());
}
Ok(vec![Command::MyHdr(rest.to_string())])
}
"unmy_hdr" => want(args, "unmy_hdr NAME...").map(|n| vec![Command::UnMyHdr(n)]),
"alias" => {
let (Some(nick), rest) = (args.first(), &args[1.min(args.len())..]) else {
return Err("alias NICK ADDRESS".into());
};
if rest.is_empty() {
return Err("alias NICK ADDRESS".into());
}
Ok(vec![Command::Alias {
nick: nick.clone(),
expansion: rest.join(" "),
}])
}
"push" => {
if args.is_empty() {
return Err("push what?".into());
}
Ok(vec![Command::Push(args.join(" "))])
}
"exec" => {
let Some(function) = args.first() else {
return Err("exec what?".into());
};
Ok(vec![Command::Exec(function.clone())])
}
other => Err(format!("unknown command {other:?}")),
}
}
fn want(args: &[String], msg: &str) -> Result<Vec<String>, String> {
if args.is_empty() {
Err(msg.to_string())
} else {
Ok(args.to_vec())
}
}
fn one_set((name, value): (String, String)) -> Command {
if let Some(base) = name.strip_suffix('?') {
return Command::Query(base.to_string());
}
if let Some(base) = name.strip_prefix("inv") {
return Command::Toggle(base.to_string());
}
if value == "no" {
return Command::Unset(name);
}
Command::Set { name, value }
}
enum Slot<'a> {
Text(&'a mut Option<String>),
Flag(&'a mut bool),
FlagOpt(&'a mut Option<bool>),
Num16(&'a mut u16),
NumUsize(&'a mut usize),
NumU64(&'a mut u64),
NumU64Opt(&'a mut Option<u64>),
NumI64Opt(&'a mut Option<i64>),
}
fn slot<'a>(cfg: &'a mut Config, name: &str) -> Option<Slot<'a>> {
use Slot::*;
Some(match name {
"index_format" => Text(&mut cfg.index.format),
"date_format" => Text(&mut cfg.index.date_format),
"sort" => Text(&mut cfg.index.sort),
"sort_aux" => Text(&mut cfg.index.sort_aux),
"pager_format" => Text(&mut cfg.pager.format),
"quote_regexp" => Text(&mut cfg.pager.quote_regexp),
"status_format" => Text(&mut cfg.ui.status_format),
"status_chars" => Text(&mut cfg.ui.status_chars),
"ts_status_format" => Text(&mut cfg.ui.title_format),
"ts_enabled" => FlagOpt(&mut cfg.ui.set_title),
"history_file" => Text(&mut cfg.ui.history_file),
"status_on_top" => FlagOpt(&mut cfg.ui.status_on_top),
"arrow_cursor" => FlagOpt(&mut cfg.ui.arrow_cursor),
"theme" => Text(&mut cfg.ui.theme),
"sendmail" => Text(&mut cfg.mail.sendmail),
"editor" | "visual" => Text(&mut cfg.mail.editor),
"print_command" => Text(&mut cfg.mail.print),
"hostname" => Text(&mut cfg.mail.hostname),
"sig_on_top" => FlagOpt(&mut cfg.mail.sig_on_top),
"user_agent" => FlagOpt(&mut cfg.mail.user_agent),
"pipe_sep" => Text(&mut cfg.mail.pipe_sep),
"pipe_decode" => FlagOpt(&mut cfg.mail.pipe_decode),
"print_decode" => FlagOpt(&mut cfg.mail.print_decode),
"pipe_split" => FlagOpt(&mut cfg.mail.pipe_split),
"print_split" => FlagOpt(&mut cfg.mail.print_split),
"query_command" => Text(&mut cfg.mail.query_command),
"trash" => Text(&mut cfg.mail.trash),
"record" => Text(&mut cfg.mail.sent),
"postponed" => Text(&mut cfg.mail.postponed),
"new_mail_command" => Text(&mut cfg.mail.new_mail_command),
"forward" => Text(&mut cfg.mail.forward),
"realname" => Text(&mut cfg.identity.name),
"pgp_sign_as" | "pgp_default_key" => Text(&mut cfg.pgp.sign_key),
"crypt_replysign" => Flag(&mut cfg.pgp.reply_sign),
"crypt_replyencrypt" => Flag(&mut cfg.pgp.reply_encrypt),
"crypt_replysignencrypted" => Flag(&mut cfg.pgp.reply_sign_encrypted),
"fast_reply" => Flag(&mut cfg.mail.fast_reply),
"askcc" => Flag(&mut cfg.mail.ask_cc),
"confirmappend" => Flag(&mut cfg.mail.confirmappend),
"save_name" => Flag(&mut cfg.mail.save_name),
"force_name" => Flag(&mut cfg.mail.force_name),
"quit" => Text(&mut cfg.mail.quit),
"postpone" => Text(&mut cfg.mail.postpone),
"recall" => Text(&mut cfg.mail.recall),
"askbcc" => Flag(&mut cfg.mail.ask_bcc),
"attribution" => Text(&mut cfg.mail.attribution),
"indent_string" => Text(&mut cfg.mail.indent_string),
"forward_format" => Text(&mut cfg.mail.forward_format),
"reply_regexp" => Text(&mut cfg.mail.reply_regexp),
"simple_search" => Text(&mut cfg.mail.simple_search),
"wrap_search" => FlagOpt(&mut cfg.mail.wrap_search),
"include" => Text(&mut cfg.mail.include),
"forward_quote" => Flag(&mut cfg.mail.forward_quote),
"signature" => Text(&mut cfg.mail.signature),
"sig_dashes" => FlagOpt(&mut cfg.mail.sig_dashes),
"abort_nosubject" => Text(&mut cfg.mail.abort_nosubject),
"abort_unmodified" => FlagOpt(&mut cfg.mail.abort_unmodified),
"autoedit" => Flag(&mut cfg.mail.autoedit),
"beep" => Flag(&mut cfg.ui.beep),
"beep_new" => Flag(&mut cfg.ui.beep_new),
"wait_key" => FlagOpt(&mut cfg.ui.wait_key),
"mark_old" => FlagOpt(&mut cfg.mail.mark_old),
"delete_untag" => FlagOpt(&mut cfg.mail.delete_untag),
"flag_safe" => Flag(&mut cfg.mail.flag_safe),
"maildir_trash" => Flag(&mut cfg.mail.maildir_trash),
"mail_check_recent" => FlagOpt(&mut cfg.mail.mail_check_recent),
"check_new" => FlagOpt(&mut cfg.mail.check_new),
"uncollapse_new" => FlagOpt(&mut cfg.index.uncollapse_new),
"menu_scroll" => FlagOpt(&mut cfg.ui.menu_scroll),
"menu_context" => NumUsize(&mut cfg.ui.menu_context),
"menu_move_off" => FlagOpt(&mut cfg.ui.menu_move_off),
"help" => FlagOpt(&mut cfg.ui.help),
"error_history" => NumUsize(&mut cfg.ui.error_history),
"sort_browser" => Text(&mut cfg.ui.sort_browser),
"sort_alias" => Text(&mut cfg.mail.sort_alias),
"shell" => Text(&mut cfg.mail.shell),
"ispell" => Text(&mut cfg.mail.ispell),
"tmpdir" => Text(&mut cfg.mail.tmpdir),
"print" => Text(&mut cfg.mail.print_confirm),
"reverse_realname" => FlagOpt(&mut cfg.identity.reverse_realname),
"tilde" => Flag(&mut cfg.pager.tilde),
"pager_stop" => Flag(&mut cfg.pager.pager_stop),
"markers" => FlagOpt(&mut cfg.pager.markers),
"smart_wrap" => FlagOpt(&mut cfg.pager.smart_wrap),
"collapse_unread" => FlagOpt(&mut cfg.index.collapse_unread),
"hide_thread_subject" => FlagOpt(&mut cfg.index.hide_thread_subject),
"strict_threads" => FlagOpt(&mut cfg.index.strict_threads),
"sort_re" => FlagOpt(&mut cfg.index.sort_re),
"uncollapse_jump" => Flag(&mut cfg.index.uncollapse_jump),
"reverse_name" => Flag(&mut cfg.identity.reverse_name),
"metoo" => Flag(&mut cfg.mail.metoo),
"text_flowed" => Flag(&mut cfg.mail.text_flowed),
"reflow_text" => FlagOpt(&mut cfg.pager.reflow_text),
"sidebar_visible" => Flag(&mut cfg.sidebar.visible),
"crypt_autosign" | "pgp_autosign" => Flag(&mut cfg.pgp.sign_by_default),
"crypt_autoencrypt" | "pgp_autoencrypt" => Flag(&mut cfg.pgp.encrypt_by_default),
"edit_headers" => FlagOpt(&mut cfg.mail.edit_headers),
"copy" => FlagOpt(&mut cfg.mail.copy),
"notmuch" => FlagOpt(&mut cfg.mail.notmuch),
"pager_index_lines" => Num16(&mut cfg.pager.index_lines),
"sidebar_width" => Num16(&mut cfg.sidebar.width),
"pager_context" => NumUsize(&mut cfg.pager.context),
"search_context" => NumUsize(&mut cfg.pager.search_context),
"mail_check" => NumU64Opt(&mut cfg.mail.poll_seconds),
"undo_send" => NumU64(&mut cfg.mail.undo_send),
"connect_timeout" => NumU64(&mut cfg.net.connect_timeout),
"net_timeout" => NumU64(&mut cfg.net.timeout),
"delete" => Text(&mut cfg.mail.delete),
"abort_noattach" => Text(&mut cfg.mail.abort_noattach),
"abort_noattach_regex" => Text(&mut cfg.mail.attach_keyword),
"wrap" => NumI64Opt(&mut cfg.pager.wrap),
_ => return None,
})
}
const SORTS: [&str; 5] = ["date", "from", "subject", "size", "threads"];
pub fn apply(cfg: &mut Config, cmd: &Command) -> Result<Option<String>, String> {
match cmd {
Command::Set { name, value } => set(cfg, name, value).map(|()| None),
Command::Unset(name) => unset(cfg, name).map(|()| None),
Command::Reset(name) => reset(cfg, name).map(|()| None),
Command::Uncolor { object, pattern } => {
uncolor(cfg, object, pattern.as_deref()).map(|()| None)
}
Command::Unhook(kind) => unhook(cfg, kind).map(|()| None),
Command::Unmailboxes(specs) => {
if specs.iter().any(|s| s == "*") {
cfg.mail.mailboxes.clear();
} else {
cfg.mail
.mailboxes
.retain(|m| !specs.iter().any(|s| same_mailbox(s, m)));
}
Ok(None)
}
Command::Toggle(name) => toggle(cfg, name).map(|()| None),
Command::Query(name) => query(cfg, name).map(Some),
Command::Color {
object,
fg,
bg,
pattern,
} => color(cfg, object, fg, bg, pattern.as_deref()).map(|()| None),
Command::Ignore(headers) => {
edit_header_list(cfg, headers, true);
Ok(None)
}
Command::Unignore(headers) => {
edit_header_list(cfg, headers, false);
Ok(None)
}
Command::Alternates(patterns) => {
for p in patterns {
if !cfg.mail.alternates.contains(p) {
cfg.mail.alternates.push(p.clone());
}
}
Ok(None)
}
Command::Unalternates(patterns) => {
for p in patterns {
if p == "*" {
cfg.mail.alternates.clear();
} else {
cfg.mail.alternates.retain(|a| a != p);
}
}
Ok(None)
}
Command::AutoView(types) => {
for t in types {
cfg.filters.entry(t.to_lowercase()).or_default();
}
Ok(None)
}
Command::UnAutoView(types) => {
for t in types {
if t == "*" {
cfg.filters.clear();
} else {
cfg.filters.remove(&t.to_lowercase());
}
}
Ok(None)
}
Command::AlternativeOrder(types) => {
for t in types {
let t = t.to_lowercase();
if !cfg.pager.alternative_order.contains(&t) {
cfg.pager.alternative_order.push(t);
}
}
Ok(None)
}
Command::UnAlternativeOrder(types) => {
for t in types {
if t == "*" {
cfg.pager.alternative_order.clear();
} else {
let t = t.to_lowercase();
cfg.pager.alternative_order.retain(|a| *a != t);
}
}
Ok(None)
}
Command::MyHdr(entry) => {
if let Some((name, _)) = entry.split_once(':') {
let name = name.trim().to_string();
cfg.mail.my_hdr.retain(|h| !header_named(h, &name));
}
cfg.mail.my_hdr.push(entry.clone());
Ok(None)
}
Command::UnMyHdr(names) => {
for name in names {
if name == "*" {
cfg.mail.my_hdr.clear();
} else {
let name = name.trim_end_matches(':');
cfg.mail.my_hdr.retain(|h| !header_named(h, name));
}
}
Ok(None)
}
Command::Bind { .. }
| Command::Macro { .. }
| Command::Alias { .. }
| Command::Unalias(_)
| Command::Push(_)
| Command::Exec(_) => Ok(None),
}
}
fn header_named(entry: &str, name: &str) -> bool {
entry
.split_once(':')
.is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
}
fn unknown(name: &str) -> String {
format!("unknown or read-only option {name:?}")
}
fn set(cfg: &mut Config, name: &str, value: &str) -> Result<(), String> {
match name {
"from" => {
let (display, addr) = split_from(value);
if let Some(display) = display {
cfg.identity.name = Some(display);
}
cfg.identity.email = Some(addr);
return Ok(());
}
"mime_forward" => {
cfg.mail.forward = Some(if is_yes(value) { "attach" } else { "inline" }.into());
return Ok(());
}
_ => {}
}
if name == "sort" || name == "sort_aux" {
let base = value.strip_prefix("reverse-").unwrap_or(value);
let known = if name == "sort" {
SORTS.contains(&base)
} else {
base == "last-date-sent" || base == "date"
};
if !known {
return Err(format!("no such sort order {value:?}"));
}
}
let num = |v: &str| -> Result<i64, String> {
v.parse::<i64>()
.map_err(|_| format!("{name} wants a number, got {v:?}"))
};
match slot(cfg, name).ok_or_else(|| unknown(name))? {
Slot::Text(field) => *field = Some(value.to_string()),
Slot::Flag(field) => *field = is_yes(value),
Slot::FlagOpt(field) => *field = Some(is_yes(value)),
Slot::Num16(field) => *field = u16::try_from(num(value)?.max(0)).unwrap_or(u16::MAX),
Slot::NumUsize(field) => *field = usize::try_from(num(value)?.max(0)).unwrap_or(0),
Slot::NumU64(field) => *field = num(value)?.max(0) as u64,
Slot::NumU64Opt(field) => *field = Some(num(value)?.max(0) as u64),
Slot::NumI64Opt(field) => *field = Some(num(value)?),
}
Ok(())
}
fn unset(cfg: &mut Config, name: &str) -> Result<(), String> {
if name == "from" {
cfg.identity.email = None;
return Ok(());
}
match slot(cfg, name).ok_or_else(|| unknown(name))? {
Slot::Text(field) => *field = None,
Slot::Flag(field) => *field = false,
Slot::FlagOpt(field) => *field = Some(false),
Slot::Num16(field) => *field = 0,
Slot::NumUsize(field) => *field = 0,
Slot::NumU64(field) => *field = 0,
Slot::NumU64Opt(field) => *field = None,
Slot::NumI64Opt(field) => *field = None,
}
Ok(())
}
fn reset(cfg: &mut Config, name: &str) -> Result<(), String> {
if name == "all" {
return Err("reset all: reload the config instead".into());
}
if name == "from" {
cfg.identity.email = None;
return Ok(());
}
let mut fresh = Config::default();
enum Val {
Text(Option<String>),
Flag(bool),
FlagOpt(Option<bool>),
N16(u16),
NUsize(usize),
NU64(u64),
NU64Opt(Option<u64>),
NI64Opt(Option<i64>),
}
let value = match slot(&mut fresh, name).ok_or_else(|| unknown(name))? {
Slot::Text(f) => Val::Text(f.clone()),
Slot::Flag(f) => Val::Flag(*f),
Slot::FlagOpt(f) => Val::FlagOpt(*f),
Slot::Num16(f) => Val::N16(*f),
Slot::NumUsize(f) => Val::NUsize(*f),
Slot::NumU64(f) => Val::NU64(*f),
Slot::NumU64Opt(f) => Val::NU64Opt(*f),
Slot::NumI64Opt(f) => Val::NI64Opt(*f),
};
match (slot(cfg, name).ok_or_else(|| unknown(name))?, value) {
(Slot::Text(f), Val::Text(v)) => *f = v,
(Slot::Flag(f), Val::Flag(v)) => *f = v,
(Slot::FlagOpt(f), Val::FlagOpt(v)) => *f = v,
(Slot::Num16(f), Val::N16(v)) => *f = v,
(Slot::NumUsize(f), Val::NUsize(v)) => *f = v,
(Slot::NumU64(f), Val::NU64(v)) => *f = v,
(Slot::NumU64Opt(f), Val::NU64Opt(v)) => *f = v,
(Slot::NumI64Opt(f), Val::NI64Opt(v)) => *f = v,
_ => unreachable!("the same name names the same slot"),
}
Ok(())
}
fn uncolor(cfg: &mut Config, object: &str, pattern: Option<&str>) -> Result<(), String> {
let mut drop = |keys: &[&str]| {
for key in keys {
cfg.colors.remove(*key);
}
};
if let Some(n) = object.strip_prefix("quoted")
&& (n.is_empty() || n.parse::<usize>().is_ok())
{
let key = if n.is_empty() {
"quoted".to_string()
} else {
format!("quoted{n}")
};
cfg.colors.remove(&key);
return Ok(());
}
match (object, pattern) {
("status", _) => drop(&["status_fg", "status_bg"]),
("search", _) => drop(&["search_fg", "search_bg"]),
("header" | "hdrdefault", _) => drop(&["header"]),
("error", _) => drop(&["error"]),
("index", Some("*")) => {
drop(&["deleted", "flagged", "tagged"]);
cfg.color_index.clear();
}
("index", Some("~D")) => drop(&["deleted"]),
("index", Some("~F")) => drop(&["flagged"]),
("index", Some("~T")) => drop(&["tagged"]),
("index", Some(pattern)) => cfg.color_index.retain(|r| r.pattern != pattern),
("body", Some("*")) => cfg.color_body.clear(),
("body", Some(pattern)) => cfg.color_body.retain(|r| r.pattern != pattern),
("index" | "body", None) => return Err(format!("uncolor {object} needs a pattern or *")),
_ => return Err(format!("no rmut color slot for {object:?}")),
}
Ok(())
}
fn unhook(cfg: &mut Config, kind: &str) -> Result<(), String> {
match kind {
"*" => {
cfg.folder_hooks.clear();
cfg.message_hooks.clear();
cfg.reply_hooks.clear();
cfg.fcc_hooks.clear();
cfg.crypt_hooks.clear();
}
"folder-hook" => cfg.folder_hooks.clear(),
"message-hook" => cfg.message_hooks.clear(),
"reply-hook" => cfg.reply_hooks.clear(),
"fcc-hook" => cfg.fcc_hooks.clear(),
"crypt-hook" => cfg.crypt_hooks.clear(),
other => {
return Err(format!(
"unhook: unknown hook type: {other} (folder-hook, message-hook, reply-hook, fcc-hook, crypt-hook, *)"
));
}
}
Ok(())
}
fn same_mailbox(a: &str, b: &str) -> bool {
a.trim_end_matches('/') == b.trim_end_matches('/')
}
fn toggle(cfg: &mut Config, name: &str) -> Result<(), String> {
match slot(cfg, name).ok_or_else(|| unknown(name))? {
Slot::Flag(field) => *field = !*field,
Slot::FlagOpt(field) => *field = Some(!field.unwrap_or(false)),
_ => return Err(format!("{name} is not a boolean")),
}
Ok(())
}
fn query(cfg: &mut Config, name: &str) -> Result<String, String> {
if name == "from" {
let value = cfg.identity.email.clone().unwrap_or_default();
return Ok(format!("from=\"{value}\""));
}
let text = match slot(cfg, name).ok_or_else(|| unknown(name))? {
Slot::Text(field) => format!("{name}=\"{}\"", field.clone().unwrap_or_default()),
Slot::Flag(field) => flag_text(name, *field),
Slot::FlagOpt(field) => flag_text(name, field.unwrap_or(false)),
Slot::Num16(field) => format!("{name}={field}"),
Slot::NumUsize(field) => format!("{name}={field}"),
Slot::NumU64(field) => format!("{name}={field}"),
Slot::NumU64Opt(field) => match field {
Some(n) => format!("{name}={n}"),
None => format!("{name} is unset"),
},
Slot::NumI64Opt(field) => match field {
Some(n) => format!("{name}={n}"),
None => format!("{name} is unset"),
},
};
Ok(text)
}
fn flag_text(name: &str, on: bool) -> String {
if on {
name.to_string()
} else {
format!("no{name}")
}
}
fn edit_header_list(cfg: &mut Config, headers: &[String], ignoring: bool) {
let clean: Vec<String> = headers
.iter()
.map(|h| h.trim_end_matches(':').to_lowercase())
.collect();
let (add, remove) = if ignoring {
(&mut cfg.pager.ignore, &mut cfg.pager.unignore)
} else {
(&mut cfg.pager.unignore, &mut cfg.pager.ignore)
};
let list = add.get_or_insert_with(Vec::new);
for name in &clean {
if !list.contains(name) {
list.push(name.clone());
}
}
if let Some(list) = remove {
list.retain(|n| !clean.contains(n));
}
}
fn color(
cfg: &mut Config,
object: &str,
fg: &str,
bg: &str,
pattern: Option<&str>,
) -> Result<(), String> {
let fg = convert_color(fg);
let bg = convert_color(bg);
let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
bg.clone()
} else {
fg.clone()
};
if let Some(n) = object.strip_prefix("quoted")
&& let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
{
let key = if depth == 0 {
"quoted".to_string()
} else {
format!("quoted{depth}")
};
cfg.colors.insert(key, vivid);
return Ok(());
}
let mut named = |key: &str, value: String| {
cfg.colors.insert(key.to_string(), value);
};
match (object, pattern) {
("status", _) => {
named("status_fg", fg);
named("status_bg", bg);
}
("search", _) => {
if fg != "default" {
named("search_fg", fg);
}
if bg != "default" {
named("search_bg", bg);
}
}
("header" | "hdrdefault", _) => named("header", fg),
("error", _) => named("error", vivid),
("index", Some("~D")) if bg == "default" => named("deleted", vivid),
("index", Some("~F")) if bg == "default" => named("flagged", vivid),
("index", Some("~T")) if bg == "default" => named("tagged", vivid),
("index", Some(pattern)) => {
crate::pattern::parse(pattern).map_err(|err| format!("bad pattern: {err}"))?;
cfg.color_index.push(rule(pattern, fg, bg));
}
("body", Some(pattern)) => {
regex_lite::Regex::new(pattern).map_err(|err| format!("bad regex: {err}"))?;
cfg.color_body.push(rule(pattern, fg, bg));
}
("index" | "body", None) => return Err(format!("color {object} needs a pattern")),
_ => return Err(format!("no rmut color slot for {object:?}")),
}
Ok(())
}
fn rule(pattern: &str, fg: String, bg: String) -> ColorRule {
ColorRule {
pattern: pattern.to_string(),
fg: (fg != "default").then_some(fg),
bg: (bg != "default").then_some(bg),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one(line: &str) -> Command {
let mut cmds = parse(line).expect("parses");
assert_eq!(cmds.len(), 1, "{line:?} yielded {cmds:?}");
cmds.pop().unwrap()
}
#[test]
fn alternates_and_my_hdr_at_the_prompt() {
let mut cfg = Config::default();
for line in [
"alternates jane@old\\.example\\.com typo@x",
"unalternates typo@x",
"my_hdr Organization: Acme",
"my_hdr X-Mailer: rmut",
"my_hdr Organization: Acme Ltd",
"unmy_hdr X-Mailer",
"set metoo",
] {
for cmd in parse(line).expect(line) {
apply(&mut cfg, &cmd).expect(line);
}
}
assert_eq!(cfg.mail.alternates, ["jane@old\\.example\\.com"]);
assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
assert!(cfg.mail.metoo);
for cmd in parse("unmy_hdr *").unwrap() {
apply(&mut cfg, &cmd).unwrap();
}
assert!(cfg.mail.my_hdr.is_empty());
assert!(parse("my_hdr Organization").is_err());
assert!(parse("alternates").is_err());
}
#[test]
fn auto_view_and_alternative_order_at_the_prompt() {
let mut cfg = Config::default();
for line in [
"auto_view text/html Application/Zip",
"auto_view text/calendar",
"unauto_view text/calendar",
"alternative_order text/plain TEXT/HTML",
"alternative_order text/plain",
"unalternative_order text/html",
] {
for cmd in parse(line).expect(line) {
apply(&mut cfg, &cmd).expect(line);
}
}
let mut types: Vec<&str> = cfg.filters.keys().map(String::as_str).collect();
types.sort_unstable();
assert_eq!(types, ["application/zip", "text/html"]);
assert!(cfg.filters.values().all(String::is_empty));
cfg.filters
.insert("text/html".into(), "w3m -dump -T text/html".into());
for cmd in parse("auto_view text/html").unwrap() {
apply(&mut cfg, &cmd).unwrap();
}
assert_eq!(
cfg.filters.get("text/html").map(String::as_str),
Some("w3m -dump -T text/html")
);
assert_eq!(cfg.pager.alternative_order, ["text/plain"]);
for cmd in parse("unauto_view *").unwrap() {
apply(&mut cfg, &cmd).unwrap();
}
assert!(cfg.filters.is_empty());
assert!(parse("auto_view").is_err());
assert!(parse("alternative_order").is_err());
}
#[test]
fn set_forms() {
assert_eq!(
one("set index_format=\"%s %f\""),
Command::Set {
name: "index_format".into(),
value: "%s %f".into()
}
);
assert_eq!(one("set nobeep"), Command::Unset("beep".into()));
assert_eq!(
one("set beep"),
Command::Set {
name: "beep".into(),
value: "yes".into()
}
);
assert_eq!(one("set invbeep"), Command::Toggle("beep".into()));
assert_eq!(one("set beep?"), Command::Query("beep".into()));
assert_eq!(one("toggle tilde"), Command::Toggle("tilde".into()));
assert_eq!(one("unset tilde"), Command::Unset("tilde".into()));
assert_eq!(parse("set a=1 b=2").unwrap().len(), 2);
assert_eq!(parse("").unwrap(), vec![]);
assert_eq!(parse("# comment").unwrap(), vec![]);
}
#[test]
fn unknown_command_and_option_report() {
assert!(parse("frobnicate x").is_err());
let mut cfg = Config::default();
let err = apply(&mut cfg, &one("set nosuchthing=1")).unwrap_err();
assert!(err.contains("nosuchthing"), "{err}");
}
#[test]
fn set_applies_to_config() {
let mut cfg = Config::default();
apply(&mut cfg, &one("set pager_index_lines=5")).unwrap();
assert_eq!(cfg.pager.index_lines, 5);
apply(&mut cfg, &one("set wrap=-8")).unwrap();
assert_eq!(cfg.pager.wrap, Some(-8));
apply(&mut cfg, &one("set tilde")).unwrap();
assert!(cfg.pager.tilde);
apply(&mut cfg, &one("toggle tilde")).unwrap();
assert!(!cfg.pager.tilde);
apply(&mut cfg, &one("set from=\"Jane Doe <jane@x>\"")).unwrap();
assert_eq!(cfg.identity.email.as_deref(), Some("jane@x"));
assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
assert!(apply(&mut cfg, &one("set pager_index_lines=x")).is_err());
assert!(apply(&mut cfg, &one("set sort=nonsense")).is_err());
apply(&mut cfg, &one("set sort=reverse-date")).unwrap();
assert_eq!(cfg.index.sort.as_deref(), Some("reverse-date"));
}
#[test]
fn query_reports_current_value() {
let mut cfg = Config::default();
cfg.index.format = Some("%s".into());
let out = apply(&mut cfg, &one("set index_format?")).unwrap();
assert_eq!(out.as_deref(), Some("index_format=\"%s\""));
let out = apply(&mut cfg, &one("set beep?")).unwrap();
assert_eq!(out.as_deref(), Some("beep"));
apply(&mut cfg, &one("unset beep")).unwrap();
let out = apply(&mut cfg, &one("set beep?")).unwrap();
assert_eq!(out.as_deref(), Some("nobeep"));
}
#[test]
fn ignore_lists_move_names_between_them() {
let mut cfg = Config::default();
apply(&mut cfg, &one("ignore x-spam: received")).unwrap();
assert_eq!(
cfg.pager.ignore.as_deref(),
Some(["x-spam".to_string(), "received".to_string()].as_slice())
);
apply(&mut cfg, &one("unignore received")).unwrap();
assert_eq!(
cfg.pager.ignore.as_deref(),
Some(["x-spam".to_string()].as_slice())
);
assert_eq!(
cfg.pager.unignore.as_deref(),
Some(["received".to_string()].as_slice())
);
}
#[test]
fn colors_land_in_their_slots() {
let mut cfg = Config::default();
apply(&mut cfg, &one("color status brightwhite blue")).unwrap();
assert_eq!(
cfg.colors.get("status_fg").map(String::as_str),
Some("lightwhite")
);
assert_eq!(
cfg.colors.get("status_bg").map(String::as_str),
Some("blue")
);
apply(&mut cfg, &one("color quoted2 green default")).unwrap();
assert_eq!(cfg.colors.get("quoted2").map(String::as_str), Some("green"));
apply(&mut cfg, &one("color index red default ~F")).unwrap();
assert_eq!(cfg.colors.get("flagged").map(String::as_str), Some("red"));
apply(&mut cfg, &one("color index red default ~f jane")).unwrap();
assert_eq!(cfg.color_index.len(), 1);
apply(&mut cfg, &one("color body yellow default TODO")).unwrap();
assert_eq!(cfg.color_body.len(), 1);
assert!(apply(&mut cfg, &one("color index red default ~q")).is_err());
assert!(apply(&mut cfg, &one("color body red default [")).is_err());
assert!(apply(&mut cfg, &one("color nosuchslot red default")).is_err());
}
#[test]
fn bind_macro_and_push_parse_but_stay_for_the_caller() {
assert_eq!(
one("bind index \\Cd delete-message"),
Command::Bind {
menu: Menu::Index,
key: "\\Cd".into(),
function: "delete-message".into()
}
);
assert_eq!(parse("bind index,pager d delete-message").unwrap().len(), 2);
assert!(parse("bind nosuchmenu d delete").is_err());
assert_eq!(
one("macro index L \"l~f jane<enter>\""),
Command::Macro {
menu: Menu::Index,
key: "L".into(),
seq: "l~f jane<enter>".into()
}
);
assert_eq!(one("push \"<enter>\""), Command::Push("<enter>".into()));
assert_eq!(one("exec sync"), Command::Exec("sync".into()));
assert_eq!(
one("alias jane Jane Doe <jane@x>"),
Command::Alias {
nick: "jane".into(),
expansion: "Jane Doe <jane@x>".into()
}
);
let mut cfg = Config::default();
assert_eq!(apply(&mut cfg, &one("exec sync")).unwrap(), None);
}
#[test]
fn reset_restores_the_default_where_unset_zeroes() {
let mut cfg = Config::default();
for line in ["set pager_context=7", "set nobeep", "set editor=vim"] {
apply(&mut cfg, &one(line)).unwrap();
}
assert_eq!(cfg.pager.context, 7);
for cmd in parse("reset pager_context beep editor").unwrap() {
apply(&mut cfg, &cmd).unwrap();
}
let fresh = Config::default();
assert_eq!(cfg.pager.context, fresh.pager.context);
assert_eq!(cfg.ui.beep, fresh.ui.beep, "back on, not off");
assert_eq!(cfg.mail.editor, fresh.mail.editor);
assert!(apply(&mut cfg, &one("reset all")).is_err());
assert!(parse("reset").is_err(), "reset wants a name");
}
#[test]
fn uncolor_takes_rules_and_slots_back_out() {
let mut cfg = Config::default();
for line in [
"color index brightyellow default ~F",
"color index red default ~f jane",
"color index blue default ~s urgent",
"color body green default ^Signed",
"color status white blue",
"color quoted1 cyan default",
] {
apply(&mut cfg, &one(line)).unwrap();
}
assert_eq!(cfg.color_index.len(), 2, "~F went to the flagged slot");
apply(&mut cfg, &one("uncolor index ~f jane")).unwrap();
assert_eq!(cfg.color_index.len(), 1);
assert_eq!(cfg.color_index[0].pattern, "~s urgent");
apply(&mut cfg, &one("uncolor index ~F")).unwrap();
assert!(!cfg.colors.contains_key("flagged"));
apply(&mut cfg, &one("uncolor index *")).unwrap();
assert!(cfg.color_index.is_empty());
apply(&mut cfg, &one("unmono body ^Signed")).unwrap();
assert!(cfg.color_body.is_empty());
apply(&mut cfg, &one("uncolor status")).unwrap();
assert!(!cfg.colors.contains_key("status_fg"));
apply(&mut cfg, &one("uncolor quoted1")).unwrap();
assert!(!cfg.colors.contains_key("quoted1"));
assert!(
apply(&mut cfg, &one("uncolor index")).is_err(),
"a pattern or *"
);
}
#[test]
fn mono_is_a_color_line_carrying_an_attribute() {
let mut cfg = Config::default();
apply(&mut cfg, &one("mono index bold ~N")).unwrap();
assert_eq!(cfg.color_index[0].fg.as_deref(), Some("bold"));
assert_eq!(cfg.color_index[0].bg, None);
assert!(parse("mono index shiny ~N").is_err(), "no such attribute");
}
#[test]
fn unhook_and_unmailboxes_clear_by_name_or_star() {
let mut cfg = Config::default();
cfg.folder_hooks.push(Default::default());
cfg.message_hooks.push(Default::default());
cfg.mail.mailboxes = vec!["~/Mail/a".into(), "~/Mail/b/".into(), "imap:w/INBOX".into()];
apply(&mut cfg, &one("unhook folder-hook")).unwrap();
assert!(cfg.folder_hooks.is_empty() && !cfg.message_hooks.is_empty());
assert!(
apply(&mut cfg, &one("unhook send-hook")).is_err(),
"not a type rmut has"
);
apply(&mut cfg, &one("unhook *")).unwrap();
assert!(cfg.message_hooks.is_empty());
apply(&mut cfg, &one("unmailboxes ~/Mail/b")).unwrap();
assert_eq!(
cfg.mail.mailboxes,
["~/Mail/a", "imap:w/INBOX"],
"a slash is no difference"
);
apply(&mut cfg, &one("unmailboxes *")).unwrap();
assert!(cfg.mail.mailboxes.is_empty());
}
}