Skip to main content

rmut_core/
command.rs

1//! mutt's enter-command (`:`): config commands typed at runtime.
2//!
3//! Parsing is shared with the muttrc importer (same tokenizer, same
4//! option and color vocabulary), so what `--import-muttrc` understands
5//! in a file is also what `:` understands at the prompt. Applying is
6//! split: everything that is pure [`Config`] state lands in [`apply`],
7//! while binds, macros, aliases, and key pushing belong to the running
8//! TUI and are returned to the caller untouched. A later folder-hook
9//! with arbitrary commands parses the same way and applies the same
10//! [`Config`] half.
11
12use crate::config::{ColorRule, Config};
13use crate::muttrc::{assignments, convert_color, is_yes, split_from, tokenize};
14
15/// Which key table a `bind`/`macro` targets. `Generic` is mutt's
16/// catch-all menu, applied to both of rmut's tables.
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Menu {
19    Index,
20    Pager,
21    Generic,
22}
23
24impl Menu {
25    fn parse(name: &str) -> Option<Menu> {
26        match name {
27            "index" => Some(Menu::Index),
28            "pager" => Some(Menu::Pager),
29            "generic" => Some(Menu::Generic),
30            _ => None,
31        }
32    }
33
34    pub fn name(self) -> &'static str {
35        match self {
36            Menu::Index => "index",
37            Menu::Pager => "pager",
38            Menu::Generic => "generic",
39        }
40    }
41}
42
43/// One command off the `:` prompt. A single line can yield several
44/// (`set a=1 b=2` is two assignments, like mutt).
45#[derive(Clone, Debug, PartialEq)]
46pub enum Command {
47    Set {
48        name: String,
49        value: String,
50    },
51    Unset(String),
52    Toggle(String),
53    /// `set name?`: report the value instead of changing it.
54    Query(String),
55    Bind {
56        menu: Menu,
57        key: String,
58        function: String,
59    },
60    Macro {
61        menu: Menu,
62        key: String,
63        seq: String,
64    },
65    Color {
66        object: String,
67        fg: String,
68        bg: String,
69        pattern: Option<String>,
70    },
71    Ignore(Vec<String>),
72    Unignore(Vec<String>),
73    /// mutt's uncolor / unmono: drop what a color line put in. `*`
74    /// as the pattern drops every rule of the object.
75    Uncolor {
76        object: String,
77        pattern: Option<String>,
78    },
79    /// mutt's unhook: drop every hook of a type, or all of them (`*`).
80    Unhook(String),
81    /// mutt's unmailboxes: forget watched mailboxes, or all (`*`).
82    Unmailboxes(Vec<String>),
83    /// mutt's unalias: drop aliases from the alias file, or all (`*`).
84    Unalias(Vec<String>),
85    /// mutt's reset: a setting back to its default.
86    Reset(String),
87    /// mutt's `alternates` / `unalternates`: regexes for my other
88    /// addresses (`*` un-does the lot).
89    Alternates(Vec<String>),
90    Unalternates(Vec<String>),
91    /// mutt's `auto_view` / `unauto_view`: MIME types rendered inline
92    /// by a filter. auto_view leaves the command to mailcap (an empty
93    /// [filters] value); `unauto_view *` clears the lot.
94    AutoView(Vec<String>),
95    UnAutoView(Vec<String>),
96    /// mutt's `alternative_order` / `unalternative_order`: which part
97    /// of a multipart/alternative wins (`*` un-does the lot).
98    AlternativeOrder(Vec<String>),
99    UnAlternativeOrder(Vec<String>),
100    /// mutt's `my_hdr`: one "Name: value" line for every draft.
101    MyHdr(String),
102    /// `unmy_hdr NAME...`, or `*` for all of them.
103    UnMyHdr(Vec<String>),
104    Alias {
105        nick: String,
106        expansion: String,
107    },
108    /// Keys fed to the input queue, as a macro sequence.
109    Push(String),
110    /// A function name run directly.
111    Exec(String),
112}
113
114/// Parse one `:` line. An empty line or a comment yields nothing; an
115/// unknown or malformed command is an error string ready for the
116/// status line.
117pub fn parse(line: &str) -> Result<Vec<Command>, String> {
118    let trimmed = line.trim();
119    if trimmed.is_empty() || trimmed.starts_with('#') {
120        return Ok(Vec::new());
121    }
122    let tokens = tokenize(trimmed);
123    let Some(cmd) = tokens.first() else {
124        return Ok(Vec::new());
125    };
126    let args = &tokens[1..];
127    match cmd.as_str() {
128        "set" => {
129            if args.is_empty() {
130                return Err("set what?".into());
131            }
132            Ok(assignments(args).into_iter().map(one_set).collect())
133        }
134        "unset" => want(args, "unset what?").map(|names| {
135            names
136                .iter()
137                .map(|n| Command::Unset(n.to_string()))
138                .collect()
139        }),
140        "reset" => want(args, "reset what?").map(|names| {
141            names
142                .iter()
143                .map(|n| Command::Reset(n.to_string()))
144                .collect()
145        }),
146        "uncolor" | "unmono" => {
147            let Some(object) = args.first() else {
148                return Err(format!("{cmd} OBJECT [PATTERN|*]"));
149            };
150            Ok(vec![Command::Uncolor {
151                object: object.clone(),
152                pattern: (args.len() > 1).then(|| args[1..].join(" ")),
153            }])
154        }
155        "mono" => {
156            // mutt's mono: an attribute where color has a colour pair.
157            // Index and body rules carry it; the rest of the objects
158            // have nowhere to keep one.
159            let (Some(object), Some(attr)) = (args.first(), args.get(1)) else {
160                return Err("mono OBJECT ATTRIBUTE [PATTERN]".into());
161            };
162            if !matches!(
163                attr.as_str(),
164                "none" | "bold" | "underline" | "reverse" | "standout"
165            ) {
166                return Err(format!("mono: no such attribute {attr:?}"));
167            }
168            Ok(vec![Command::Color {
169                object: object.clone(),
170                fg: attr.clone(),
171                bg: "default".into(),
172                pattern: (args.len() > 2).then(|| args[2..].join(" ")),
173            }])
174        }
175        "unhook" => want(args, "unhook TYPE|*").map(|types| {
176            types
177                .iter()
178                .map(|t| Command::Unhook(t.to_string()))
179                .collect()
180        }),
181        "unmailboxes" => {
182            want(args, "unmailboxes MAILBOX...|*").map(|m| vec![Command::Unmailboxes(m)])
183        }
184        "unalias" => want(args, "unalias NICK...|*").map(|n| vec![Command::Unalias(n)]),
185        "toggle" => want(args, "toggle what?").map(|names| {
186            names
187                .iter()
188                .map(|n| Command::Toggle(n.to_string()))
189                .collect()
190        }),
191        "bind" | "macro" => {
192            let (Some(menus), Some(key)) = (args.first(), args.get(1)) else {
193                return Err(format!("{cmd} MENU KEY ..."));
194            };
195            let Some(rest) = args.get(2) else {
196                return Err(if cmd == "bind" {
197                    "bind MENU KEY FUNCTION".into()
198                } else {
199                    "macro MENU KEY SEQUENCE".into()
200                });
201            };
202            let mut out = Vec::new();
203            for name in menus.split(',') {
204                let Some(menu) = Menu::parse(name) else {
205                    return Err(format!("unknown menu {name:?}"));
206                };
207                out.push(if cmd == "bind" {
208                    Command::Bind {
209                        menu,
210                        key: key.clone(),
211                        function: rest.clone(),
212                    }
213                } else {
214                    Command::Macro {
215                        menu,
216                        key: key.clone(),
217                        seq: rest.clone(),
218                    }
219                });
220            }
221            Ok(out)
222        }
223        "color" => {
224            let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2))
225            else {
226                return Err("color OBJECT FG BG [PATTERN]".into());
227            };
228            Ok(vec![Command::Color {
229                object: object.clone(),
230                fg: fg.clone(),
231                bg: bg.clone(),
232                // The pattern is the rest of the line: quoting it is
233                // mutt's habit, but an unquoted one still reads whole.
234                pattern: (args.len() > 3).then(|| args[3..].join(" ")),
235            }])
236        }
237        "ignore" => want(args, "ignore which headers?").map(|h| vec![Command::Ignore(h)]),
238        "unignore" => want(args, "unignore which headers?").map(|h| vec![Command::Unignore(h)]),
239        "alternates" => want(args, "alternates PATTERN...").map(|p| vec![Command::Alternates(p)]),
240        "unalternates" => {
241            want(args, "unalternates PATTERN...").map(|p| vec![Command::Unalternates(p)])
242        }
243        "auto_view" => want(args, "auto_view MIMETYPE...").map(|t| vec![Command::AutoView(t)]),
244        "unauto_view" => {
245            want(args, "unauto_view MIMETYPE...").map(|t| vec![Command::UnAutoView(t)])
246        }
247        "alternative_order" => {
248            want(args, "alternative_order MIMETYPE...").map(|t| vec![Command::AlternativeOrder(t)])
249        }
250        "unalternative_order" => want(args, "unalternative_order MIMETYPE...")
251            .map(|t| vec![Command::UnAlternativeOrder(t)]),
252        "my_hdr" => {
253            // The value carries colons and spaces, so it comes off the
254            // raw line rather than from the tokens.
255            let rest = trimmed["my_hdr".len()..].trim().trim_matches('"');
256            if !rest.contains(':') || rest.starts_with(':') {
257                return Err("my_hdr \"Name: value\"".into());
258            }
259            Ok(vec![Command::MyHdr(rest.to_string())])
260        }
261        "unmy_hdr" => want(args, "unmy_hdr NAME...").map(|n| vec![Command::UnMyHdr(n)]),
262        "alias" => {
263            let (Some(nick), rest) = (args.first(), &args[1.min(args.len())..]) else {
264                return Err("alias NICK ADDRESS".into());
265            };
266            if rest.is_empty() {
267                return Err("alias NICK ADDRESS".into());
268            }
269            Ok(vec![Command::Alias {
270                nick: nick.clone(),
271                expansion: rest.join(" "),
272            }])
273        }
274        "push" => {
275            if args.is_empty() {
276                return Err("push what?".into());
277            }
278            Ok(vec![Command::Push(args.join(" "))])
279        }
280        "exec" => {
281            let Some(function) = args.first() else {
282                return Err("exec what?".into());
283            };
284            Ok(vec![Command::Exec(function.clone())])
285        }
286        other => Err(format!("unknown command {other:?}")),
287    }
288}
289
290fn want(args: &[String], msg: &str) -> Result<Vec<String>, String> {
291    if args.is_empty() {
292        Err(msg.to_string())
293    } else {
294        Ok(args.to_vec())
295    }
296}
297
298/// One `set` assignment, decoding mutt's `no`/`inv` prefixes and the
299/// trailing `?` that [`assignments`] leaves on the name.
300fn one_set((name, value): (String, String)) -> Command {
301    if let Some(base) = name.strip_suffix('?') {
302        return Command::Query(base.to_string());
303    }
304    if let Some(base) = name.strip_prefix("inv") {
305        return Command::Toggle(base.to_string());
306    }
307    if value == "no" {
308        return Command::Unset(name);
309    }
310    Command::Set { name, value }
311}
312
313/// A settable option as a mutable handle into the live config.
314enum Slot<'a> {
315    Text(&'a mut Option<String>),
316    Flag(&'a mut bool),
317    FlagOpt(&'a mut Option<bool>),
318    Num16(&'a mut u16),
319    NumUsize(&'a mut usize),
320    NumU64(&'a mut u64),
321    NumU64Opt(&'a mut Option<u64>),
322    NumI64Opt(&'a mut Option<i64>),
323}
324
325/// mutt's option name to the config field it drives. Options whose
326/// value needs decoding first (`from`, `mime_forward`) are handled by
327/// the caller, and anything not listed here is not runtime-settable.
328fn slot<'a>(cfg: &'a mut Config, name: &str) -> Option<Slot<'a>> {
329    use Slot::*;
330    Some(match name {
331        "index_format" => Text(&mut cfg.index.format),
332        "date_format" => Text(&mut cfg.index.date_format),
333        "sort" => Text(&mut cfg.index.sort),
334        "sort_aux" => Text(&mut cfg.index.sort_aux),
335        "pager_format" => Text(&mut cfg.pager.format),
336        "quote_regexp" => Text(&mut cfg.pager.quote_regexp),
337        "status_format" => Text(&mut cfg.ui.status_format),
338        "status_chars" => Text(&mut cfg.ui.status_chars),
339        "ts_status_format" => Text(&mut cfg.ui.title_format),
340        "ts_enabled" => FlagOpt(&mut cfg.ui.set_title),
341        "history_file" => Text(&mut cfg.ui.history_file),
342        "status_on_top" => FlagOpt(&mut cfg.ui.status_on_top),
343        "arrow_cursor" => FlagOpt(&mut cfg.ui.arrow_cursor),
344        "theme" => Text(&mut cfg.ui.theme),
345        "sendmail" => Text(&mut cfg.mail.sendmail),
346        "editor" | "visual" => Text(&mut cfg.mail.editor),
347        "print_command" => Text(&mut cfg.mail.print),
348        "hostname" => Text(&mut cfg.mail.hostname),
349        "sig_on_top" => FlagOpt(&mut cfg.mail.sig_on_top),
350        "user_agent" => FlagOpt(&mut cfg.mail.user_agent),
351        "pipe_sep" => Text(&mut cfg.mail.pipe_sep),
352        "pipe_decode" => FlagOpt(&mut cfg.mail.pipe_decode),
353        "print_decode" => FlagOpt(&mut cfg.mail.print_decode),
354        "pipe_split" => FlagOpt(&mut cfg.mail.pipe_split),
355        "print_split" => FlagOpt(&mut cfg.mail.print_split),
356        "query_command" => Text(&mut cfg.mail.query_command),
357        "trash" => Text(&mut cfg.mail.trash),
358        "record" => Text(&mut cfg.mail.sent),
359        "postponed" => Text(&mut cfg.mail.postponed),
360        "new_mail_command" => Text(&mut cfg.mail.new_mail_command),
361        "forward" => Text(&mut cfg.mail.forward),
362        "realname" => Text(&mut cfg.identity.name),
363        "pgp_sign_as" | "pgp_default_key" => Text(&mut cfg.pgp.sign_key),
364        "crypt_replysign" => Flag(&mut cfg.pgp.reply_sign),
365        "crypt_replyencrypt" => Flag(&mut cfg.pgp.reply_encrypt),
366        "crypt_replysignencrypted" => Flag(&mut cfg.pgp.reply_sign_encrypted),
367        "fast_reply" => Flag(&mut cfg.mail.fast_reply),
368        "askcc" => Flag(&mut cfg.mail.ask_cc),
369        "confirmappend" => Flag(&mut cfg.mail.confirmappend),
370        "save_name" => Flag(&mut cfg.mail.save_name),
371        "force_name" => Flag(&mut cfg.mail.force_name),
372        "quit" => Text(&mut cfg.mail.quit),
373        "postpone" => Text(&mut cfg.mail.postpone),
374        "recall" => Text(&mut cfg.mail.recall),
375        "askbcc" => Flag(&mut cfg.mail.ask_bcc),
376        "attribution" => Text(&mut cfg.mail.attribution),
377        "indent_string" => Text(&mut cfg.mail.indent_string),
378        "forward_format" => Text(&mut cfg.mail.forward_format),
379        "reply_regexp" => Text(&mut cfg.mail.reply_regexp),
380        "simple_search" => Text(&mut cfg.mail.simple_search),
381        "wrap_search" => FlagOpt(&mut cfg.mail.wrap_search),
382        "include" => Text(&mut cfg.mail.include),
383        "forward_quote" => Flag(&mut cfg.mail.forward_quote),
384        "signature" => Text(&mut cfg.mail.signature),
385        "sig_dashes" => FlagOpt(&mut cfg.mail.sig_dashes),
386        "abort_nosubject" => Text(&mut cfg.mail.abort_nosubject),
387        "abort_unmodified" => FlagOpt(&mut cfg.mail.abort_unmodified),
388        "autoedit" => Flag(&mut cfg.mail.autoedit),
389        "beep" => Flag(&mut cfg.ui.beep),
390        "beep_new" => Flag(&mut cfg.ui.beep_new),
391        "wait_key" => FlagOpt(&mut cfg.ui.wait_key),
392        "mark_old" => FlagOpt(&mut cfg.mail.mark_old),
393        "delete_untag" => FlagOpt(&mut cfg.mail.delete_untag),
394        "flag_safe" => Flag(&mut cfg.mail.flag_safe),
395        "maildir_trash" => Flag(&mut cfg.mail.maildir_trash),
396        "mail_check_recent" => FlagOpt(&mut cfg.mail.mail_check_recent),
397        "check_new" => FlagOpt(&mut cfg.mail.check_new),
398        "uncollapse_new" => FlagOpt(&mut cfg.index.uncollapse_new),
399        "menu_scroll" => FlagOpt(&mut cfg.ui.menu_scroll),
400        "menu_context" => NumUsize(&mut cfg.ui.menu_context),
401        "menu_move_off" => FlagOpt(&mut cfg.ui.menu_move_off),
402        "help" => FlagOpt(&mut cfg.ui.help),
403        "error_history" => NumUsize(&mut cfg.ui.error_history),
404        "sort_browser" => Text(&mut cfg.ui.sort_browser),
405        "sort_alias" => Text(&mut cfg.mail.sort_alias),
406        "shell" => Text(&mut cfg.mail.shell),
407        "ispell" => Text(&mut cfg.mail.ispell),
408        "tmpdir" => Text(&mut cfg.mail.tmpdir),
409        "print" => Text(&mut cfg.mail.print_confirm),
410        "reverse_realname" => FlagOpt(&mut cfg.identity.reverse_realname),
411        "tilde" => Flag(&mut cfg.pager.tilde),
412        "pager_stop" => Flag(&mut cfg.pager.pager_stop),
413        "markers" => FlagOpt(&mut cfg.pager.markers),
414        "smart_wrap" => FlagOpt(&mut cfg.pager.smart_wrap),
415        "collapse_unread" => FlagOpt(&mut cfg.index.collapse_unread),
416        "hide_thread_subject" => FlagOpt(&mut cfg.index.hide_thread_subject),
417        "strict_threads" => FlagOpt(&mut cfg.index.strict_threads),
418        "sort_re" => FlagOpt(&mut cfg.index.sort_re),
419        "uncollapse_jump" => Flag(&mut cfg.index.uncollapse_jump),
420        "reverse_name" => Flag(&mut cfg.identity.reverse_name),
421        "metoo" => Flag(&mut cfg.mail.metoo),
422        "text_flowed" => Flag(&mut cfg.mail.text_flowed),
423        "reflow_text" => FlagOpt(&mut cfg.pager.reflow_text),
424        "sidebar_visible" => Flag(&mut cfg.sidebar.visible),
425        "crypt_autosign" | "pgp_autosign" => Flag(&mut cfg.pgp.sign_by_default),
426        "crypt_autoencrypt" | "pgp_autoencrypt" => Flag(&mut cfg.pgp.encrypt_by_default),
427        "edit_headers" => FlagOpt(&mut cfg.mail.edit_headers),
428        "copy" => FlagOpt(&mut cfg.mail.copy),
429        "notmuch" => FlagOpt(&mut cfg.mail.notmuch),
430        "pager_index_lines" => Num16(&mut cfg.pager.index_lines),
431        "sidebar_width" => Num16(&mut cfg.sidebar.width),
432        "pager_context" => NumUsize(&mut cfg.pager.context),
433        "search_context" => NumUsize(&mut cfg.pager.search_context),
434        "mail_check" => NumU64Opt(&mut cfg.mail.poll_seconds),
435        "undo_send" => NumU64(&mut cfg.mail.undo_send),
436        "connect_timeout" => NumU64(&mut cfg.net.connect_timeout),
437        "net_timeout" => NumU64(&mut cfg.net.timeout),
438        "delete" => Text(&mut cfg.mail.delete),
439        "abort_noattach" => Text(&mut cfg.mail.abort_noattach),
440        "abort_noattach_regex" => Text(&mut cfg.mail.attach_keyword),
441        "wrap" => NumI64Opt(&mut cfg.pager.wrap),
442        // mutt's older name for use_envelope_from is envelope_from.
443        "use_envelope_from" | "envelope_from" => Flag(&mut cfg.mail.use_envelope_from),
444        "envelope_from_address" => Text(&mut cfg.mail.envelope_from_address),
445        "dsn_notify" => Text(&mut cfg.mail.dsn_notify),
446        "dsn_return" => Text(&mut cfg.mail.dsn_return),
447        "reply_self" => Flag(&mut cfg.mail.reply_self),
448        "fcc_attach" => Text(&mut cfg.mail.fcc_attach),
449        "fcc_clear" => Flag(&mut cfg.mail.fcc_clear),
450        "forward_edit" => Text(&mut cfg.mail.forward_edit),
451        "mime_forward_rest" => FlagOpt(&mut cfg.mail.mime_forward_rest),
452        "hyperlinks" => FlagOpt(&mut cfg.ui.hyperlinks),
453        "url_command" => Text(&mut cfg.ui.url_command),
454        _ => return None,
455    })
456}
457
458/// Sort orders the index understands, shared by `set sort` and the
459/// config loader's own vocabulary.
460const SORTS: [&str; 5] = ["date", "from", "subject", "size", "threads"];
461
462/// Apply the [`Config`] half of a command. `Ok(Some(text))` is a
463/// message for the status line (a query's answer); `Ok(None)` is a
464/// silent success, mutt-style. Commands the TUI owns (bind, macro,
465/// alias, push, exec) return `Ok(None)` without touching the config,
466/// so the caller must handle those itself.
467pub fn apply(cfg: &mut Config, cmd: &Command) -> Result<Option<String>, String> {
468    match cmd {
469        Command::Set { name, value } => set(cfg, name, value).map(|()| None),
470        Command::Unset(name) => unset(cfg, name).map(|()| None),
471        Command::Reset(name) => reset(cfg, name).map(|()| None),
472        Command::Uncolor { object, pattern } => {
473            uncolor(cfg, object, pattern.as_deref()).map(|()| None)
474        }
475        Command::Unhook(kind) => unhook(cfg, kind).map(|()| None),
476        Command::Unmailboxes(specs) => {
477            if specs.iter().any(|s| s == "*") {
478                cfg.mail.mailboxes.clear();
479            } else {
480                cfg.mail
481                    .mailboxes
482                    .retain(|m| !specs.iter().any(|s| same_mailbox(s, m)));
483            }
484            Ok(None)
485        }
486
487        Command::Toggle(name) => toggle(cfg, name).map(|()| None),
488        Command::Query(name) => query(cfg, name).map(Some),
489        Command::Color {
490            object,
491            fg,
492            bg,
493            pattern,
494        } => color(cfg, object, fg, bg, pattern.as_deref()).map(|()| None),
495        Command::Ignore(headers) => {
496            edit_header_list(cfg, headers, true);
497            Ok(None)
498        }
499        Command::Unignore(headers) => {
500            edit_header_list(cfg, headers, false);
501            Ok(None)
502        }
503        Command::Alternates(patterns) => {
504            for p in patterns {
505                if !cfg.mail.alternates.contains(p) {
506                    cfg.mail.alternates.push(p.clone());
507                }
508            }
509            Ok(None)
510        }
511        Command::Unalternates(patterns) => {
512            for p in patterns {
513                if p == "*" {
514                    cfg.mail.alternates.clear();
515                } else {
516                    cfg.mail.alternates.retain(|a| a != p);
517                }
518            }
519            Ok(None)
520        }
521        Command::AutoView(types) => {
522            for t in types {
523                // The command comes from mailcap, like mutt; a
524                // [filters] entry set by hand is left alone.
525                cfg.filters.entry(t.to_lowercase()).or_default();
526            }
527            Ok(None)
528        }
529        Command::UnAutoView(types) => {
530            for t in types {
531                if t == "*" {
532                    cfg.filters.clear();
533                } else {
534                    cfg.filters.remove(&t.to_lowercase());
535                }
536            }
537            Ok(None)
538        }
539        Command::AlternativeOrder(types) => {
540            for t in types {
541                let t = t.to_lowercase();
542                if !cfg.pager.alternative_order.contains(&t) {
543                    cfg.pager.alternative_order.push(t);
544                }
545            }
546            Ok(None)
547        }
548        Command::UnAlternativeOrder(types) => {
549            for t in types {
550                if t == "*" {
551                    cfg.pager.alternative_order.clear();
552                } else {
553                    let t = t.to_lowercase();
554                    cfg.pager.alternative_order.retain(|a| *a != t);
555                }
556            }
557            Ok(None)
558        }
559        Command::MyHdr(entry) => {
560            // One my_hdr per header name, like mutt: a second line for
561            // the same header replaces the first.
562            if let Some((name, _)) = entry.split_once(':') {
563                let name = name.trim().to_string();
564                cfg.mail.my_hdr.retain(|h| !header_named(h, &name));
565            }
566            cfg.mail.my_hdr.push(entry.clone());
567            Ok(None)
568        }
569        Command::UnMyHdr(names) => {
570            for name in names {
571                if name == "*" {
572                    cfg.mail.my_hdr.clear();
573                } else {
574                    let name = name.trim_end_matches(':');
575                    cfg.mail.my_hdr.retain(|h| !header_named(h, name));
576                }
577            }
578            Ok(None)
579        }
580        Command::Bind { .. }
581        | Command::Macro { .. }
582        | Command::Alias { .. }
583        | Command::Unalias(_)
584        | Command::Push(_)
585        | Command::Exec(_) => Ok(None),
586    }
587}
588
589/// True when a stored `my_hdr` line carries this header name.
590fn header_named(entry: &str, name: &str) -> bool {
591    entry
592        .split_once(':')
593        .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
594}
595
596fn unknown(name: &str) -> String {
597    format!("unknown or read-only option {name:?}")
598}
599
600fn set(cfg: &mut Config, name: &str, value: &str) -> Result<(), String> {
601    // mutt's from carries an optional display name; mime_forward is a
602    // boolean spelling of rmut's forward mode.
603    match name {
604        "from" => {
605            let (display, addr) = split_from(value);
606            if let Some(display) = display {
607                cfg.identity.name = Some(display);
608            }
609            cfg.identity.email = Some(addr);
610            return Ok(());
611        }
612        "mime_forward" => {
613            cfg.mail.forward = Some(if is_yes(value) { "attach" } else { "inline" }.into());
614            return Ok(());
615        }
616        _ => {}
617    }
618    if name == "sort" || name == "sort_aux" {
619        let base = value.strip_prefix("reverse-").unwrap_or(value);
620        let known = if name == "sort" {
621            SORTS.contains(&base)
622        } else {
623            base == "last-date-sent" || base == "date"
624        };
625        if !known {
626            return Err(format!("no such sort order {value:?}"));
627        }
628    }
629    let num = |v: &str| -> Result<i64, String> {
630        v.parse::<i64>()
631            .map_err(|_| format!("{name} wants a number, got {v:?}"))
632    };
633    match slot(cfg, name).ok_or_else(|| unknown(name))? {
634        Slot::Text(field) => *field = Some(value.to_string()),
635        Slot::Flag(field) => *field = is_yes(value),
636        Slot::FlagOpt(field) => *field = Some(is_yes(value)),
637        Slot::Num16(field) => *field = u16::try_from(num(value)?.max(0)).unwrap_or(u16::MAX),
638        Slot::NumUsize(field) => *field = usize::try_from(num(value)?.max(0)).unwrap_or(0),
639        Slot::NumU64(field) => *field = num(value)?.max(0) as u64,
640        Slot::NumU64Opt(field) => *field = Some(num(value)?.max(0) as u64),
641        Slot::NumI64Opt(field) => *field = Some(num(value)?),
642    }
643    Ok(())
644}
645
646/// `unset`: booleans go false, everything else goes back to rmut's
647/// built-in default (an unset field), which is what a muttrc-less
648/// session would have used.
649fn unset(cfg: &mut Config, name: &str) -> Result<(), String> {
650    if name == "from" {
651        cfg.identity.email = None;
652        return Ok(());
653    }
654    // A quadoption whose default is yes: unset means no, as in mutt,
655    // not back to the default.
656    let quad = match name {
657        "fcc_attach" => Some(&mut cfg.mail.fcc_attach),
658        "forward_edit" => Some(&mut cfg.mail.forward_edit),
659        _ => None,
660    };
661    if let Some(field) = quad {
662        *field = Some("no".into());
663        return Ok(());
664    }
665    match slot(cfg, name).ok_or_else(|| unknown(name))? {
666        Slot::Text(field) => *field = None,
667        Slot::Flag(field) => *field = false,
668        Slot::FlagOpt(field) => *field = Some(false),
669        Slot::Num16(field) => *field = 0,
670        Slot::NumUsize(field) => *field = 0,
671        Slot::NumU64(field) => *field = 0,
672        Slot::NumU64Opt(field) => *field = None,
673        Slot::NumI64Opt(field) => *field = None,
674    }
675    Ok(())
676}
677
678/// mutt's reset: the setting as a fresh Config has it.
679fn reset(cfg: &mut Config, name: &str) -> Result<(), String> {
680    if name == "all" {
681        return Err("reset all: reload the config instead".into());
682    }
683    if name == "from" {
684        cfg.identity.email = None;
685        return Ok(());
686    }
687    let mut fresh = Config::default();
688    // Read the default out first: two slots into two configs, one at
689    // a time, so neither borrow outlives its use.
690    enum Val {
691        Text(Option<String>),
692        Flag(bool),
693        FlagOpt(Option<bool>),
694        N16(u16),
695        NUsize(usize),
696        NU64(u64),
697        NU64Opt(Option<u64>),
698        NI64Opt(Option<i64>),
699    }
700    let value = match slot(&mut fresh, name).ok_or_else(|| unknown(name))? {
701        Slot::Text(f) => Val::Text(f.clone()),
702        Slot::Flag(f) => Val::Flag(*f),
703        Slot::FlagOpt(f) => Val::FlagOpt(*f),
704        Slot::Num16(f) => Val::N16(*f),
705        Slot::NumUsize(f) => Val::NUsize(*f),
706        Slot::NumU64(f) => Val::NU64(*f),
707        Slot::NumU64Opt(f) => Val::NU64Opt(*f),
708        Slot::NumI64Opt(f) => Val::NI64Opt(*f),
709    };
710    match (slot(cfg, name).ok_or_else(|| unknown(name))?, value) {
711        (Slot::Text(f), Val::Text(v)) => *f = v,
712        (Slot::Flag(f), Val::Flag(v)) => *f = v,
713        (Slot::FlagOpt(f), Val::FlagOpt(v)) => *f = v,
714        (Slot::Num16(f), Val::N16(v)) => *f = v,
715        (Slot::NumUsize(f), Val::NUsize(v)) => *f = v,
716        (Slot::NumU64(f), Val::NU64(v)) => *f = v,
717        (Slot::NumU64Opt(f), Val::NU64Opt(v)) => *f = v,
718        (Slot::NumI64Opt(f), Val::NI64Opt(v)) => *f = v,
719        _ => unreachable!("the same name names the same slot"),
720    }
721    Ok(())
722}
723
724/// mutt's uncolor / unmono, undoing what [`color`] did with the same
725/// object: rules by pattern (or all of them with `*`), the named
726/// slots outright.
727fn uncolor(cfg: &mut Config, object: &str, pattern: Option<&str>) -> Result<(), String> {
728    let mut drop = |keys: &[&str]| {
729        for key in keys {
730            cfg.colors.remove(*key);
731        }
732    };
733    if let Some(n) = object.strip_prefix("quoted")
734        && (n.is_empty() || n.parse::<usize>().is_ok())
735    {
736        let key = if n.is_empty() {
737            "quoted".to_string()
738        } else {
739            format!("quoted{n}")
740        };
741        cfg.colors.remove(&key);
742        return Ok(());
743    }
744    match (object, pattern) {
745        ("status", _) => drop(&["status_fg", "status_bg"]),
746        ("search", _) => drop(&["search_fg", "search_bg"]),
747        ("header" | "hdrdefault", _) => drop(&["header"]),
748        ("error", _) => drop(&["error"]),
749        ("index", Some("*")) => {
750            drop(&["deleted", "flagged", "tagged"]);
751            cfg.color_index.clear();
752        }
753        ("index", Some("~D")) => drop(&["deleted"]),
754        ("index", Some("~F")) => drop(&["flagged"]),
755        ("index", Some("~T")) => drop(&["tagged"]),
756        ("index", Some(pattern)) => cfg.color_index.retain(|r| r.pattern != pattern),
757        ("body", Some("*")) => cfg.color_body.clear(),
758        ("body", Some(pattern)) => cfg.color_body.retain(|r| r.pattern != pattern),
759        ("index" | "body", None) => return Err(format!("uncolor {object} needs a pattern or *")),
760        _ => return Err(format!("no rmut color slot for {object:?}")),
761    }
762    Ok(())
763}
764
765/// mutt's unhook: every hook of one type, or all of them.
766fn unhook(cfg: &mut Config, kind: &str) -> Result<(), String> {
767    match kind {
768        "*" => {
769            cfg.folder_hooks.clear();
770            cfg.message_hooks.clear();
771            cfg.reply_hooks.clear();
772            cfg.fcc_hooks.clear();
773            cfg.crypt_hooks.clear();
774        }
775        "folder-hook" => cfg.folder_hooks.clear(),
776        "message-hook" => cfg.message_hooks.clear(),
777        "reply-hook" => cfg.reply_hooks.clear(),
778        "fcc-hook" => cfg.fcc_hooks.clear(),
779        "crypt-hook" => cfg.crypt_hooks.clear(),
780        other => {
781            return Err(format!(
782                "unhook: unknown hook type: {other} (folder-hook, message-hook, reply-hook, fcc-hook, crypt-hook, *)"
783            ));
784        }
785    }
786    Ok(())
787}
788
789/// Whether two mailbox spellings name the same mailbox: as written,
790/// or with a trailing slash more or less.
791fn same_mailbox(a: &str, b: &str) -> bool {
792    a.trim_end_matches('/') == b.trim_end_matches('/')
793}
794
795fn toggle(cfg: &mut Config, name: &str) -> Result<(), String> {
796    match slot(cfg, name).ok_or_else(|| unknown(name))? {
797        Slot::Flag(field) => *field = !*field,
798        Slot::FlagOpt(field) => *field = Some(!field.unwrap_or(false)),
799        _ => return Err(format!("{name} is not a boolean")),
800    }
801    Ok(())
802}
803
804fn query(cfg: &mut Config, name: &str) -> Result<String, String> {
805    if name == "from" {
806        let value = cfg.identity.email.clone().unwrap_or_default();
807        return Ok(format!("from=\"{value}\""));
808    }
809    let text = match slot(cfg, name).ok_or_else(|| unknown(name))? {
810        Slot::Text(field) => format!("{name}=\"{}\"", field.clone().unwrap_or_default()),
811        Slot::Flag(field) => flag_text(name, *field),
812        Slot::FlagOpt(field) => flag_text(name, field.unwrap_or(false)),
813        Slot::Num16(field) => format!("{name}={field}"),
814        Slot::NumUsize(field) => format!("{name}={field}"),
815        Slot::NumU64(field) => format!("{name}={field}"),
816        Slot::NumU64Opt(field) => match field {
817            Some(n) => format!("{name}={n}"),
818            None => format!("{name} is unset"),
819        },
820        Slot::NumI64Opt(field) => match field {
821            Some(n) => format!("{name}={n}"),
822            None => format!("{name} is unset"),
823        },
824    };
825    Ok(text)
826}
827
828fn flag_text(name: &str, on: bool) -> String {
829    if on {
830        name.to_string()
831    } else {
832        format!("no{name}")
833    }
834}
835
836/// `ignore`/`unignore`: rmut keeps both lists, so each command adds to
837/// its own and drops the name from the other (mutt's unignore is the
838/// same undo, expressed against one list).
839fn edit_header_list(cfg: &mut Config, headers: &[String], ignoring: bool) {
840    let clean: Vec<String> = headers
841        .iter()
842        .map(|h| h.trim_end_matches(':').to_lowercase())
843        .collect();
844    let (add, remove) = if ignoring {
845        (&mut cfg.pager.ignore, &mut cfg.pager.unignore)
846    } else {
847        (&mut cfg.pager.unignore, &mut cfg.pager.ignore)
848    };
849    let list = add.get_or_insert_with(Vec::new);
850    for name in &clean {
851        if !list.contains(name) {
852            list.push(name.clone());
853        }
854    }
855    if let Some(list) = remove {
856        list.retain(|n| !clean.contains(n));
857    }
858}
859
860/// `color OBJECT FG BG [PATTERN]`, with the importer's slot mapping:
861/// named slots land in `[colors]`, index patterns become
862/// `[[color_index]]` rules, and body regexes `[[color_body]]`.
863fn color(
864    cfg: &mut Config,
865    object: &str,
866    fg: &str,
867    bg: &str,
868    pattern: Option<&str>,
869) -> Result<(), String> {
870    let fg = convert_color(fg);
871    let bg = convert_color(bg);
872    // A foreground saying nothing (black/white/default over a colored
873    // background) means the background is the visible color.
874    let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
875        bg.clone()
876    } else {
877        fg.clone()
878    };
879    if let Some(n) = object.strip_prefix("quoted")
880        && let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
881    {
882        let key = if depth == 0 {
883            "quoted".to_string()
884        } else {
885            format!("quoted{depth}")
886        };
887        cfg.colors.insert(key, vivid);
888        return Ok(());
889    }
890    let mut named = |key: &str, value: String| {
891        cfg.colors.insert(key.to_string(), value);
892    };
893    match (object, pattern) {
894        ("status", _) => {
895            named("status_fg", fg);
896            named("status_bg", bg);
897        }
898        ("search", _) => {
899            if fg != "default" {
900                named("search_fg", fg);
901            }
902            if bg != "default" {
903                named("search_bg", bg);
904            }
905        }
906        ("header" | "hdrdefault", _) => named("header", fg),
907        ("error", _) => named("error", vivid),
908        // One colour goes to the built-in slot; a line that paints a
909        // background as well keeps both, as a [[color_index]] rule.
910        ("index", Some("~D")) if bg == "default" => named("deleted", vivid),
911        ("index", Some("~F")) if bg == "default" => named("flagged", vivid),
912        ("index", Some("~T")) if bg == "default" => named("tagged", vivid),
913        ("index", Some(pattern)) => {
914            crate::pattern::parse(pattern).map_err(|err| format!("bad pattern: {err}"))?;
915            cfg.color_index.push(rule(pattern, fg, bg));
916        }
917        ("body", Some(pattern)) => {
918            regex_lite::Regex::new(pattern).map_err(|err| format!("bad regex: {err}"))?;
919            cfg.color_body.push(rule(pattern, fg, bg));
920        }
921        ("index" | "body", None) => return Err(format!("color {object} needs a pattern")),
922        _ => return Err(format!("no rmut color slot for {object:?}")),
923    }
924    Ok(())
925}
926
927fn rule(pattern: &str, fg: String, bg: String) -> ColorRule {
928    ColorRule {
929        pattern: pattern.to_string(),
930        fg: (fg != "default").then_some(fg),
931        bg: (bg != "default").then_some(bg),
932    }
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938
939    fn one(line: &str) -> Command {
940        let mut cmds = parse(line).expect("parses");
941        assert_eq!(cmds.len(), 1, "{line:?} yielded {cmds:?}");
942        cmds.pop().unwrap()
943    }
944
945    #[test]
946    fn alternates_and_my_hdr_at_the_prompt() {
947        let mut cfg = Config::default();
948        for line in [
949            "alternates jane@old\\.example\\.com typo@x",
950            "unalternates typo@x",
951            "my_hdr Organization: Acme",
952            "my_hdr X-Mailer: rmut",
953            "my_hdr Organization: Acme Ltd",
954            "unmy_hdr X-Mailer",
955            "set metoo",
956        ] {
957            for cmd in parse(line).expect(line) {
958                apply(&mut cfg, &cmd).expect(line);
959            }
960        }
961        assert_eq!(cfg.mail.alternates, ["jane@old\\.example\\.com"]);
962        assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
963        assert!(cfg.mail.metoo);
964        for cmd in parse("unmy_hdr *").unwrap() {
965            apply(&mut cfg, &cmd).unwrap();
966        }
967        assert!(cfg.mail.my_hdr.is_empty());
968        assert!(parse("my_hdr Organization").is_err());
969        assert!(parse("alternates").is_err());
970    }
971
972    #[test]
973    fn envelope_options_at_the_prompt() {
974        let mut cfg = Config::default();
975        for line in [
976            "set envelope_from",
977            "set envelope_from_address=\"Bounces <bounces@example.com>\"",
978            "set dsn_notify=failure,delay dsn_return=hdrs",
979            "set reply_self fcc_clear",
980            "unset fcc_attach",
981            "unset forward_edit",
982            "set mime_forward_rest=no",
983        ] {
984            for cmd in parse(line).expect(line) {
985                apply(&mut cfg, &cmd).expect(line);
986            }
987        }
988        let mail = &cfg.mail;
989        assert!(mail.use_envelope_from && mail.reply_self && mail.fcc_clear);
990        assert_eq!(mail.fcc_attach.as_deref(), Some("no"));
991        assert_eq!(mail.forward_edit.as_deref(), Some("no"));
992        assert_eq!(mail.mime_forward_rest, Some(false));
993        let envelope = mail.envelope("jane@example.com");
994        assert_eq!(envelope.sender.as_deref(), Some("bounces@example.com"));
995        assert_eq!(envelope.notify.as_deref(), Some("failure,delay"));
996        assert_eq!(envelope.ret.as_deref(), Some("hdrs"));
997        // Without an address of its own the envelope sender is the From;
998        // with use_envelope_from off nobody insists on one.
999        cfg.mail.envelope_from_address = None;
1000        let from = cfg.mail.envelope("jane@example.com").sender;
1001        assert_eq!(from.as_deref(), Some("jane@example.com"));
1002        cfg.mail.use_envelope_from = false;
1003        assert_eq!(cfg.mail.envelope("jane@example.com").sender, None);
1004    }
1005
1006    #[test]
1007    fn auto_view_and_alternative_order_at_the_prompt() {
1008        let mut cfg = Config::default();
1009        for line in [
1010            "auto_view text/html Application/Zip",
1011            "auto_view text/calendar",
1012            "unauto_view text/calendar",
1013            "alternative_order text/plain TEXT/HTML",
1014            "alternative_order text/plain",
1015            "unalternative_order text/html",
1016        ] {
1017            for cmd in parse(line).expect(line) {
1018                apply(&mut cfg, &cmd).expect(line);
1019            }
1020        }
1021        // auto_view leaves the command to mailcap: an empty value.
1022        let mut types: Vec<&str> = cfg.filters.keys().map(String::as_str).collect();
1023        types.sort_unstable();
1024        assert_eq!(types, ["application/zip", "text/html"]);
1025        assert!(cfg.filters.values().all(String::is_empty));
1026        // A command set by hand survives a later auto_view for the type.
1027        cfg.filters
1028            .insert("text/html".into(), "w3m -dump -T text/html".into());
1029        for cmd in parse("auto_view text/html").unwrap() {
1030            apply(&mut cfg, &cmd).unwrap();
1031        }
1032        assert_eq!(
1033            cfg.filters.get("text/html").map(String::as_str),
1034            Some("w3m -dump -T text/html")
1035        );
1036        // The order keeps its first mention and drops duplicates.
1037        assert_eq!(cfg.pager.alternative_order, ["text/plain"]);
1038        for cmd in parse("unauto_view *").unwrap() {
1039            apply(&mut cfg, &cmd).unwrap();
1040        }
1041        assert!(cfg.filters.is_empty());
1042        assert!(parse("auto_view").is_err());
1043        assert!(parse("alternative_order").is_err());
1044    }
1045
1046    #[test]
1047    fn set_forms() {
1048        assert_eq!(
1049            one("set index_format=\"%s %f\""),
1050            Command::Set {
1051                name: "index_format".into(),
1052                value: "%s %f".into()
1053            }
1054        );
1055        assert_eq!(one("set nobeep"), Command::Unset("beep".into()));
1056        assert_eq!(
1057            one("set beep"),
1058            Command::Set {
1059                name: "beep".into(),
1060                value: "yes".into()
1061            }
1062        );
1063        assert_eq!(one("set invbeep"), Command::Toggle("beep".into()));
1064        assert_eq!(one("set beep?"), Command::Query("beep".into()));
1065        assert_eq!(one("toggle tilde"), Command::Toggle("tilde".into()));
1066        assert_eq!(one("unset tilde"), Command::Unset("tilde".into()));
1067        assert_eq!(parse("set a=1 b=2").unwrap().len(), 2);
1068        assert_eq!(parse("").unwrap(), vec![]);
1069        assert_eq!(parse("# comment").unwrap(), vec![]);
1070    }
1071
1072    #[test]
1073    fn unknown_command_and_option_report() {
1074        assert!(parse("frobnicate x").is_err());
1075        let mut cfg = Config::default();
1076        let err = apply(&mut cfg, &one("set nosuchthing=1")).unwrap_err();
1077        assert!(err.contains("nosuchthing"), "{err}");
1078    }
1079
1080    #[test]
1081    fn set_applies_to_config() {
1082        let mut cfg = Config::default();
1083        apply(&mut cfg, &one("set pager_index_lines=5")).unwrap();
1084        assert_eq!(cfg.pager.index_lines, 5);
1085        apply(&mut cfg, &one("set wrap=-8")).unwrap();
1086        assert_eq!(cfg.pager.wrap, Some(-8));
1087        apply(&mut cfg, &one("set tilde")).unwrap();
1088        assert!(cfg.pager.tilde);
1089        apply(&mut cfg, &one("toggle tilde")).unwrap();
1090        assert!(!cfg.pager.tilde);
1091        apply(&mut cfg, &one("set from=\"Jane Doe <jane@x>\"")).unwrap();
1092        assert_eq!(cfg.identity.email.as_deref(), Some("jane@x"));
1093        assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
1094        // Numbers and sort orders are checked, not silently accepted.
1095        assert!(apply(&mut cfg, &one("set pager_index_lines=x")).is_err());
1096        assert!(apply(&mut cfg, &one("set sort=nonsense")).is_err());
1097        apply(&mut cfg, &one("set sort=reverse-date")).unwrap();
1098        assert_eq!(cfg.index.sort.as_deref(), Some("reverse-date"));
1099    }
1100
1101    #[test]
1102    fn query_reports_current_value() {
1103        let mut cfg = Config::default();
1104        cfg.index.format = Some("%s".into());
1105        let out = apply(&mut cfg, &one("set index_format?")).unwrap();
1106        assert_eq!(out.as_deref(), Some("index_format=\"%s\""));
1107        let out = apply(&mut cfg, &one("set beep?")).unwrap();
1108        assert_eq!(out.as_deref(), Some("beep"));
1109        apply(&mut cfg, &one("unset beep")).unwrap();
1110        let out = apply(&mut cfg, &one("set beep?")).unwrap();
1111        assert_eq!(out.as_deref(), Some("nobeep"));
1112    }
1113
1114    #[test]
1115    fn ignore_lists_move_names_between_them() {
1116        let mut cfg = Config::default();
1117        apply(&mut cfg, &one("ignore x-spam: received")).unwrap();
1118        assert_eq!(
1119            cfg.pager.ignore.as_deref(),
1120            Some(["x-spam".to_string(), "received".to_string()].as_slice())
1121        );
1122        apply(&mut cfg, &one("unignore received")).unwrap();
1123        assert_eq!(
1124            cfg.pager.ignore.as_deref(),
1125            Some(["x-spam".to_string()].as_slice())
1126        );
1127        assert_eq!(
1128            cfg.pager.unignore.as_deref(),
1129            Some(["received".to_string()].as_slice())
1130        );
1131    }
1132
1133    #[test]
1134    fn colors_land_in_their_slots() {
1135        let mut cfg = Config::default();
1136        apply(&mut cfg, &one("color status brightwhite blue")).unwrap();
1137        assert_eq!(
1138            cfg.colors.get("status_fg").map(String::as_str),
1139            Some("lightwhite")
1140        );
1141        assert_eq!(
1142            cfg.colors.get("status_bg").map(String::as_str),
1143            Some("blue")
1144        );
1145        apply(&mut cfg, &one("color quoted2 green default")).unwrap();
1146        assert_eq!(cfg.colors.get("quoted2").map(String::as_str), Some("green"));
1147        apply(&mut cfg, &one("color index red default ~F")).unwrap();
1148        assert_eq!(cfg.colors.get("flagged").map(String::as_str), Some("red"));
1149        apply(&mut cfg, &one("color index red default ~f jane")).unwrap();
1150        assert_eq!(cfg.color_index.len(), 1);
1151        apply(&mut cfg, &one("color body yellow default TODO")).unwrap();
1152        assert_eq!(cfg.color_body.len(), 1);
1153        // A pattern or regex that does not compile is refused.
1154        assert!(apply(&mut cfg, &one("color index red default ~q")).is_err());
1155        assert!(apply(&mut cfg, &one("color body red default [")).is_err());
1156        assert!(apply(&mut cfg, &one("color nosuchslot red default")).is_err());
1157    }
1158
1159    #[test]
1160    fn bind_macro_and_push_parse_but_stay_for_the_caller() {
1161        assert_eq!(
1162            one("bind index \\Cd delete-message"),
1163            Command::Bind {
1164                menu: Menu::Index,
1165                key: "\\Cd".into(),
1166                function: "delete-message".into()
1167            }
1168        );
1169        assert_eq!(parse("bind index,pager d delete-message").unwrap().len(), 2);
1170        assert!(parse("bind nosuchmenu d delete").is_err());
1171        assert_eq!(
1172            one("macro index L \"l~f jane<enter>\""),
1173            Command::Macro {
1174                menu: Menu::Index,
1175                key: "L".into(),
1176                seq: "l~f jane<enter>".into()
1177            }
1178        );
1179        assert_eq!(one("push \"<enter>\""), Command::Push("<enter>".into()));
1180        assert_eq!(one("exec sync"), Command::Exec("sync".into()));
1181        assert_eq!(
1182            one("alias jane Jane Doe <jane@x>"),
1183            Command::Alias {
1184                nick: "jane".into(),
1185                expansion: "Jane Doe <jane@x>".into()
1186            }
1187        );
1188        // The config half leaves them alone.
1189        let mut cfg = Config::default();
1190        assert_eq!(apply(&mut cfg, &one("exec sync")).unwrap(), None);
1191    }
1192
1193    #[test]
1194    fn reset_restores_the_default_where_unset_zeroes() {
1195        let mut cfg = Config::default();
1196        for line in ["set pager_context=7", "set nobeep", "set editor=vim"] {
1197            apply(&mut cfg, &one(line)).unwrap();
1198        }
1199        assert_eq!(cfg.pager.context, 7);
1200        for cmd in parse("reset pager_context beep editor").unwrap() {
1201            apply(&mut cfg, &cmd).unwrap();
1202        }
1203        let fresh = Config::default();
1204        assert_eq!(cfg.pager.context, fresh.pager.context);
1205        assert_eq!(cfg.ui.beep, fresh.ui.beep, "back on, not off");
1206        assert_eq!(cfg.mail.editor, fresh.mail.editor);
1207        assert!(apply(&mut cfg, &one("reset all")).is_err());
1208        assert!(parse("reset").is_err(), "reset wants a name");
1209    }
1210
1211    #[test]
1212    fn uncolor_takes_rules_and_slots_back_out() {
1213        let mut cfg = Config::default();
1214        for line in [
1215            "color index brightyellow default ~F",
1216            "color index red default ~f jane",
1217            "color index blue default ~s urgent",
1218            "color body green default ^Signed",
1219            "color status white blue",
1220            "color quoted1 cyan default",
1221        ] {
1222            apply(&mut cfg, &one(line)).unwrap();
1223        }
1224        assert_eq!(cfg.color_index.len(), 2, "~F went to the flagged slot");
1225        apply(&mut cfg, &one("uncolor index ~f jane")).unwrap();
1226        assert_eq!(cfg.color_index.len(), 1);
1227        assert_eq!(cfg.color_index[0].pattern, "~s urgent");
1228        apply(&mut cfg, &one("uncolor index ~F")).unwrap();
1229        assert!(!cfg.colors.contains_key("flagged"));
1230        apply(&mut cfg, &one("uncolor index *")).unwrap();
1231        assert!(cfg.color_index.is_empty());
1232        apply(&mut cfg, &one("unmono body ^Signed")).unwrap();
1233        assert!(cfg.color_body.is_empty());
1234        apply(&mut cfg, &one("uncolor status")).unwrap();
1235        assert!(!cfg.colors.contains_key("status_fg"));
1236        apply(&mut cfg, &one("uncolor quoted1")).unwrap();
1237        assert!(!cfg.colors.contains_key("quoted1"));
1238        assert!(
1239            apply(&mut cfg, &one("uncolor index")).is_err(),
1240            "a pattern or *"
1241        );
1242    }
1243
1244    #[test]
1245    fn mono_is_a_color_line_carrying_an_attribute() {
1246        let mut cfg = Config::default();
1247        apply(&mut cfg, &one("mono index bold ~N")).unwrap();
1248        assert_eq!(cfg.color_index[0].fg.as_deref(), Some("bold"));
1249        assert_eq!(cfg.color_index[0].bg, None);
1250        assert!(parse("mono index shiny ~N").is_err(), "no such attribute");
1251    }
1252
1253    #[test]
1254    fn unhook_and_unmailboxes_clear_by_name_or_star() {
1255        let mut cfg = Config::default();
1256        cfg.folder_hooks.push(Default::default());
1257        cfg.message_hooks.push(Default::default());
1258        cfg.mail.mailboxes = vec!["~/Mail/a".into(), "~/Mail/b/".into(), "imap:w/INBOX".into()];
1259        apply(&mut cfg, &one("unhook folder-hook")).unwrap();
1260        assert!(cfg.folder_hooks.is_empty() && !cfg.message_hooks.is_empty());
1261        assert!(
1262            apply(&mut cfg, &one("unhook send-hook")).is_err(),
1263            "not a type rmut has"
1264        );
1265        apply(&mut cfg, &one("unhook *")).unwrap();
1266        assert!(cfg.message_hooks.is_empty());
1267        apply(&mut cfg, &one("unmailboxes ~/Mail/b")).unwrap();
1268        assert_eq!(
1269            cfg.mail.mailboxes,
1270            ["~/Mail/a", "imap:w/INBOX"],
1271            "a slash is no difference"
1272        );
1273        apply(&mut cfg, &one("unmailboxes *")).unwrap();
1274        assert!(cfg.mail.mailboxes.is_empty());
1275    }
1276}