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 `alternates` / `unalternates`: regexes for my other
74    /// addresses (`*` un-does the lot).
75    Alternates(Vec<String>),
76    Unalternates(Vec<String>),
77    /// mutt's `auto_view` / `unauto_view`: MIME types rendered inline
78    /// by a filter. auto_view leaves the command to mailcap (an empty
79    /// [filters] value); `unauto_view *` clears the lot.
80    AutoView(Vec<String>),
81    UnAutoView(Vec<String>),
82    /// mutt's `alternative_order` / `unalternative_order`: which part
83    /// of a multipart/alternative wins (`*` un-does the lot).
84    AlternativeOrder(Vec<String>),
85    UnAlternativeOrder(Vec<String>),
86    /// mutt's `my_hdr`: one "Name: value" line for every draft.
87    MyHdr(String),
88    /// `unmy_hdr NAME...`, or `*` for all of them.
89    UnMyHdr(Vec<String>),
90    Alias {
91        nick: String,
92        expansion: String,
93    },
94    /// Keys fed to the input queue, as a macro sequence.
95    Push(String),
96    /// A function name run directly.
97    Exec(String),
98}
99
100/// Parse one `:` line. An empty line or a comment yields nothing; an
101/// unknown or malformed command is an error string ready for the
102/// status line.
103pub fn parse(line: &str) -> Result<Vec<Command>, String> {
104    let trimmed = line.trim();
105    if trimmed.is_empty() || trimmed.starts_with('#') {
106        return Ok(Vec::new());
107    }
108    let tokens = tokenize(trimmed);
109    let Some(cmd) = tokens.first() else {
110        return Ok(Vec::new());
111    };
112    let args = &tokens[1..];
113    match cmd.as_str() {
114        "set" => {
115            if args.is_empty() {
116                return Err("set what?".into());
117            }
118            Ok(assignments(args).into_iter().map(one_set).collect())
119        }
120        "unset" | "reset" => want(args, "unset what?").map(|names| {
121            names
122                .iter()
123                .map(|n| Command::Unset(n.to_string()))
124                .collect()
125        }),
126        "toggle" => want(args, "toggle what?").map(|names| {
127            names
128                .iter()
129                .map(|n| Command::Toggle(n.to_string()))
130                .collect()
131        }),
132        "bind" | "macro" => {
133            let (Some(menus), Some(key)) = (args.first(), args.get(1)) else {
134                return Err(format!("{cmd} MENU KEY ..."));
135            };
136            let Some(rest) = args.get(2) else {
137                return Err(if cmd == "bind" {
138                    "bind MENU KEY FUNCTION".into()
139                } else {
140                    "macro MENU KEY SEQUENCE".into()
141                });
142            };
143            let mut out = Vec::new();
144            for name in menus.split(',') {
145                let Some(menu) = Menu::parse(name) else {
146                    return Err(format!("unknown menu {name:?}"));
147                };
148                out.push(if cmd == "bind" {
149                    Command::Bind {
150                        menu,
151                        key: key.clone(),
152                        function: rest.clone(),
153                    }
154                } else {
155                    Command::Macro {
156                        menu,
157                        key: key.clone(),
158                        seq: rest.clone(),
159                    }
160                });
161            }
162            Ok(out)
163        }
164        "color" => {
165            let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2))
166            else {
167                return Err("color OBJECT FG BG [PATTERN]".into());
168            };
169            Ok(vec![Command::Color {
170                object: object.clone(),
171                fg: fg.clone(),
172                bg: bg.clone(),
173                // The pattern is the rest of the line: quoting it is
174                // mutt's habit, but an unquoted one still reads whole.
175                pattern: (args.len() > 3).then(|| args[3..].join(" ")),
176            }])
177        }
178        "ignore" => want(args, "ignore which headers?").map(|h| vec![Command::Ignore(h)]),
179        "unignore" => want(args, "unignore which headers?").map(|h| vec![Command::Unignore(h)]),
180        "alternates" => want(args, "alternates PATTERN...").map(|p| vec![Command::Alternates(p)]),
181        "unalternates" => {
182            want(args, "unalternates PATTERN...").map(|p| vec![Command::Unalternates(p)])
183        }
184        "auto_view" => want(args, "auto_view MIMETYPE...").map(|t| vec![Command::AutoView(t)]),
185        "unauto_view" => {
186            want(args, "unauto_view MIMETYPE...").map(|t| vec![Command::UnAutoView(t)])
187        }
188        "alternative_order" => {
189            want(args, "alternative_order MIMETYPE...").map(|t| vec![Command::AlternativeOrder(t)])
190        }
191        "unalternative_order" => want(args, "unalternative_order MIMETYPE...")
192            .map(|t| vec![Command::UnAlternativeOrder(t)]),
193        "my_hdr" => {
194            // The value carries colons and spaces, so it comes off the
195            // raw line rather than from the tokens.
196            let rest = trimmed["my_hdr".len()..].trim().trim_matches('"');
197            if !rest.contains(':') || rest.starts_with(':') {
198                return Err("my_hdr \"Name: value\"".into());
199            }
200            Ok(vec![Command::MyHdr(rest.to_string())])
201        }
202        "unmy_hdr" => want(args, "unmy_hdr NAME...").map(|n| vec![Command::UnMyHdr(n)]),
203        "alias" => {
204            let (Some(nick), rest) = (args.first(), &args[1.min(args.len())..]) else {
205                return Err("alias NICK ADDRESS".into());
206            };
207            if rest.is_empty() {
208                return Err("alias NICK ADDRESS".into());
209            }
210            Ok(vec![Command::Alias {
211                nick: nick.clone(),
212                expansion: rest.join(" "),
213            }])
214        }
215        "push" => {
216            if args.is_empty() {
217                return Err("push what?".into());
218            }
219            Ok(vec![Command::Push(args.join(" "))])
220        }
221        "exec" => {
222            let Some(function) = args.first() else {
223                return Err("exec what?".into());
224            };
225            Ok(vec![Command::Exec(function.clone())])
226        }
227        other => Err(format!("unknown command {other:?}")),
228    }
229}
230
231fn want(args: &[String], msg: &str) -> Result<Vec<String>, String> {
232    if args.is_empty() {
233        Err(msg.to_string())
234    } else {
235        Ok(args.to_vec())
236    }
237}
238
239/// One `set` assignment, decoding mutt's `no`/`inv` prefixes and the
240/// trailing `?` that [`assignments`] leaves on the name.
241fn one_set((name, value): (String, String)) -> Command {
242    if let Some(base) = name.strip_suffix('?') {
243        return Command::Query(base.to_string());
244    }
245    if let Some(base) = name.strip_prefix("inv") {
246        return Command::Toggle(base.to_string());
247    }
248    if value == "no" {
249        return Command::Unset(name);
250    }
251    Command::Set { name, value }
252}
253
254/// A settable option as a mutable handle into the live config.
255enum Slot<'a> {
256    Text(&'a mut Option<String>),
257    Flag(&'a mut bool),
258    FlagOpt(&'a mut Option<bool>),
259    Num16(&'a mut u16),
260    NumUsize(&'a mut usize),
261    NumU64(&'a mut u64),
262    NumU64Opt(&'a mut Option<u64>),
263    NumI64Opt(&'a mut Option<i64>),
264}
265
266/// mutt's option name to the config field it drives. Options whose
267/// value needs decoding first (`from`, `mime_forward`) are handled by
268/// the caller, and anything not listed here is not runtime-settable.
269fn slot<'a>(cfg: &'a mut Config, name: &str) -> Option<Slot<'a>> {
270    use Slot::*;
271    Some(match name {
272        "index_format" => Text(&mut cfg.index.format),
273        "date_format" => Text(&mut cfg.index.date_format),
274        "sort" => Text(&mut cfg.index.sort),
275        "sort_aux" => Text(&mut cfg.index.sort_aux),
276        "pager_format" => Text(&mut cfg.pager.format),
277        "quote_regexp" => Text(&mut cfg.pager.quote_regexp),
278        "status_format" => Text(&mut cfg.ui.status_format),
279        "status_chars" => Text(&mut cfg.ui.status_chars),
280        "ts_status_format" => Text(&mut cfg.ui.title_format),
281        "ts_enabled" => FlagOpt(&mut cfg.ui.set_title),
282        "history_file" => Text(&mut cfg.ui.history_file),
283        "status_on_top" => FlagOpt(&mut cfg.ui.status_on_top),
284        "arrow_cursor" => FlagOpt(&mut cfg.ui.arrow_cursor),
285        "theme" => Text(&mut cfg.ui.theme),
286        "sendmail" => Text(&mut cfg.mail.sendmail),
287        "editor" | "visual" => Text(&mut cfg.mail.editor),
288        "print_command" => Text(&mut cfg.mail.print),
289        "hostname" => Text(&mut cfg.mail.hostname),
290        "sig_on_top" => FlagOpt(&mut cfg.mail.sig_on_top),
291        "user_agent" => FlagOpt(&mut cfg.mail.user_agent),
292        "pipe_sep" => Text(&mut cfg.mail.pipe_sep),
293        "pipe_decode" => FlagOpt(&mut cfg.mail.pipe_decode),
294        "print_decode" => FlagOpt(&mut cfg.mail.print_decode),
295        "pipe_split" => FlagOpt(&mut cfg.mail.pipe_split),
296        "print_split" => FlagOpt(&mut cfg.mail.print_split),
297        "query_command" => Text(&mut cfg.mail.query_command),
298        "trash" => Text(&mut cfg.mail.trash),
299        "record" => Text(&mut cfg.mail.sent),
300        "postponed" => Text(&mut cfg.mail.postponed),
301        "new_mail_command" => Text(&mut cfg.mail.new_mail_command),
302        "forward" => Text(&mut cfg.mail.forward),
303        "realname" => Text(&mut cfg.identity.name),
304        "pgp_sign_as" | "pgp_default_key" => Text(&mut cfg.pgp.sign_key),
305        "crypt_replysign" => Flag(&mut cfg.pgp.reply_sign),
306        "crypt_replyencrypt" => Flag(&mut cfg.pgp.reply_encrypt),
307        "crypt_replysignencrypted" => Flag(&mut cfg.pgp.reply_sign_encrypted),
308        "fast_reply" => Flag(&mut cfg.mail.fast_reply),
309        "askcc" => Flag(&mut cfg.mail.ask_cc),
310        "confirmappend" => Flag(&mut cfg.mail.confirmappend),
311        "save_name" => Flag(&mut cfg.mail.save_name),
312        "force_name" => Flag(&mut cfg.mail.force_name),
313        "quit" => Text(&mut cfg.mail.quit),
314        "postpone" => Text(&mut cfg.mail.postpone),
315        "recall" => Text(&mut cfg.mail.recall),
316        "askbcc" => Flag(&mut cfg.mail.ask_bcc),
317        "attribution" => Text(&mut cfg.mail.attribution),
318        "indent_string" => Text(&mut cfg.mail.indent_string),
319        "forward_format" => Text(&mut cfg.mail.forward_format),
320        "reply_regexp" => Text(&mut cfg.mail.reply_regexp),
321        "simple_search" => Text(&mut cfg.mail.simple_search),
322        "wrap_search" => FlagOpt(&mut cfg.mail.wrap_search),
323        "include" => Text(&mut cfg.mail.include),
324        "forward_quote" => Flag(&mut cfg.mail.forward_quote),
325        "signature" => Text(&mut cfg.mail.signature),
326        "sig_dashes" => FlagOpt(&mut cfg.mail.sig_dashes),
327        "abort_nosubject" => Text(&mut cfg.mail.abort_nosubject),
328        "abort_unmodified" => FlagOpt(&mut cfg.mail.abort_unmodified),
329        "autoedit" => Flag(&mut cfg.mail.autoedit),
330        "beep" => Flag(&mut cfg.ui.beep),
331        "beep_new" => Flag(&mut cfg.ui.beep_new),
332        "wait_key" => FlagOpt(&mut cfg.ui.wait_key),
333        "mark_old" => FlagOpt(&mut cfg.mail.mark_old),
334        "print" => Text(&mut cfg.mail.print_confirm),
335        "reverse_realname" => FlagOpt(&mut cfg.identity.reverse_realname),
336        "tilde" => Flag(&mut cfg.pager.tilde),
337        "pager_stop" => Flag(&mut cfg.pager.pager_stop),
338        "markers" => FlagOpt(&mut cfg.pager.markers),
339        "smart_wrap" => FlagOpt(&mut cfg.pager.smart_wrap),
340        "collapse_unread" => FlagOpt(&mut cfg.index.collapse_unread),
341        "hide_thread_subject" => FlagOpt(&mut cfg.index.hide_thread_subject),
342        "strict_threads" => FlagOpt(&mut cfg.index.strict_threads),
343        "sort_re" => FlagOpt(&mut cfg.index.sort_re),
344        "uncollapse_jump" => Flag(&mut cfg.index.uncollapse_jump),
345        "reverse_name" => Flag(&mut cfg.identity.reverse_name),
346        "metoo" => Flag(&mut cfg.mail.metoo),
347        "text_flowed" => Flag(&mut cfg.mail.text_flowed),
348        "reflow_text" => FlagOpt(&mut cfg.pager.reflow_text),
349        "sidebar_visible" => Flag(&mut cfg.sidebar.visible),
350        "crypt_autosign" | "pgp_autosign" => Flag(&mut cfg.pgp.sign_by_default),
351        "crypt_autoencrypt" | "pgp_autoencrypt" => Flag(&mut cfg.pgp.encrypt_by_default),
352        "edit_headers" => FlagOpt(&mut cfg.mail.edit_headers),
353        "copy" => FlagOpt(&mut cfg.mail.copy),
354        "notmuch" => FlagOpt(&mut cfg.mail.notmuch),
355        "pager_index_lines" => Num16(&mut cfg.pager.index_lines),
356        "sidebar_width" => Num16(&mut cfg.sidebar.width),
357        "pager_context" => NumUsize(&mut cfg.pager.context),
358        "search_context" => NumUsize(&mut cfg.pager.search_context),
359        "mail_check" => NumU64Opt(&mut cfg.mail.poll_seconds),
360        "undo_send" => NumU64(&mut cfg.mail.undo_send),
361        "connect_timeout" => NumU64(&mut cfg.net.connect_timeout),
362        "net_timeout" => NumU64(&mut cfg.net.timeout),
363        "delete" => Text(&mut cfg.mail.delete),
364        "abort_noattach" => Text(&mut cfg.mail.abort_noattach),
365        "abort_noattach_regex" => Text(&mut cfg.mail.attach_keyword),
366        "wrap" => NumI64Opt(&mut cfg.pager.wrap),
367        _ => return None,
368    })
369}
370
371/// Sort orders the index understands, shared by `set sort` and the
372/// config loader's own vocabulary.
373const SORTS: [&str; 5] = ["date", "from", "subject", "size", "threads"];
374
375/// Apply the [`Config`] half of a command. `Ok(Some(text))` is a
376/// message for the status line (a query's answer); `Ok(None)` is a
377/// silent success, mutt-style. Commands the TUI owns (bind, macro,
378/// alias, push, exec) return `Ok(None)` without touching the config,
379/// so the caller must handle those itself.
380pub fn apply(cfg: &mut Config, cmd: &Command) -> Result<Option<String>, String> {
381    match cmd {
382        Command::Set { name, value } => set(cfg, name, value).map(|()| None),
383        Command::Unset(name) => unset(cfg, name).map(|()| None),
384        Command::Toggle(name) => toggle(cfg, name).map(|()| None),
385        Command::Query(name) => query(cfg, name).map(Some),
386        Command::Color {
387            object,
388            fg,
389            bg,
390            pattern,
391        } => color(cfg, object, fg, bg, pattern.as_deref()).map(|()| None),
392        Command::Ignore(headers) => {
393            edit_header_list(cfg, headers, true);
394            Ok(None)
395        }
396        Command::Unignore(headers) => {
397            edit_header_list(cfg, headers, false);
398            Ok(None)
399        }
400        Command::Alternates(patterns) => {
401            for p in patterns {
402                if !cfg.mail.alternates.contains(p) {
403                    cfg.mail.alternates.push(p.clone());
404                }
405            }
406            Ok(None)
407        }
408        Command::Unalternates(patterns) => {
409            for p in patterns {
410                if p == "*" {
411                    cfg.mail.alternates.clear();
412                } else {
413                    cfg.mail.alternates.retain(|a| a != p);
414                }
415            }
416            Ok(None)
417        }
418        Command::AutoView(types) => {
419            for t in types {
420                // The command comes from mailcap, like mutt; a
421                // [filters] entry set by hand is left alone.
422                cfg.filters.entry(t.to_lowercase()).or_default();
423            }
424            Ok(None)
425        }
426        Command::UnAutoView(types) => {
427            for t in types {
428                if t == "*" {
429                    cfg.filters.clear();
430                } else {
431                    cfg.filters.remove(&t.to_lowercase());
432                }
433            }
434            Ok(None)
435        }
436        Command::AlternativeOrder(types) => {
437            for t in types {
438                let t = t.to_lowercase();
439                if !cfg.pager.alternative_order.contains(&t) {
440                    cfg.pager.alternative_order.push(t);
441                }
442            }
443            Ok(None)
444        }
445        Command::UnAlternativeOrder(types) => {
446            for t in types {
447                if t == "*" {
448                    cfg.pager.alternative_order.clear();
449                } else {
450                    let t = t.to_lowercase();
451                    cfg.pager.alternative_order.retain(|a| *a != t);
452                }
453            }
454            Ok(None)
455        }
456        Command::MyHdr(entry) => {
457            // One my_hdr per header name, like mutt: a second line for
458            // the same header replaces the first.
459            if let Some((name, _)) = entry.split_once(':') {
460                let name = name.trim().to_string();
461                cfg.mail.my_hdr.retain(|h| !header_named(h, &name));
462            }
463            cfg.mail.my_hdr.push(entry.clone());
464            Ok(None)
465        }
466        Command::UnMyHdr(names) => {
467            for name in names {
468                if name == "*" {
469                    cfg.mail.my_hdr.clear();
470                } else {
471                    let name = name.trim_end_matches(':');
472                    cfg.mail.my_hdr.retain(|h| !header_named(h, name));
473                }
474            }
475            Ok(None)
476        }
477        Command::Bind { .. }
478        | Command::Macro { .. }
479        | Command::Alias { .. }
480        | Command::Push(_)
481        | Command::Exec(_) => Ok(None),
482    }
483}
484
485/// True when a stored `my_hdr` line carries this header name.
486fn header_named(entry: &str, name: &str) -> bool {
487    entry
488        .split_once(':')
489        .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
490}
491
492fn unknown(name: &str) -> String {
493    format!("unknown or read-only option {name:?}")
494}
495
496fn set(cfg: &mut Config, name: &str, value: &str) -> Result<(), String> {
497    // mutt's from carries an optional display name; mime_forward is a
498    // boolean spelling of rmut's forward mode.
499    match name {
500        "from" => {
501            let (display, addr) = split_from(value);
502            if let Some(display) = display {
503                cfg.identity.name = Some(display);
504            }
505            cfg.identity.email = Some(addr);
506            return Ok(());
507        }
508        "mime_forward" => {
509            cfg.mail.forward = Some(if is_yes(value) { "attach" } else { "inline" }.into());
510            return Ok(());
511        }
512        _ => {}
513    }
514    if name == "sort" || name == "sort_aux" {
515        let base = value.strip_prefix("reverse-").unwrap_or(value);
516        let known = if name == "sort" {
517            SORTS.contains(&base)
518        } else {
519            base == "last-date-sent" || base == "date"
520        };
521        if !known {
522            return Err(format!("no such sort order {value:?}"));
523        }
524    }
525    let num = |v: &str| -> Result<i64, String> {
526        v.parse::<i64>()
527            .map_err(|_| format!("{name} wants a number, got {v:?}"))
528    };
529    match slot(cfg, name).ok_or_else(|| unknown(name))? {
530        Slot::Text(field) => *field = Some(value.to_string()),
531        Slot::Flag(field) => *field = is_yes(value),
532        Slot::FlagOpt(field) => *field = Some(is_yes(value)),
533        Slot::Num16(field) => *field = u16::try_from(num(value)?.max(0)).unwrap_or(u16::MAX),
534        Slot::NumUsize(field) => *field = usize::try_from(num(value)?.max(0)).unwrap_or(0),
535        Slot::NumU64(field) => *field = num(value)?.max(0) as u64,
536        Slot::NumU64Opt(field) => *field = Some(num(value)?.max(0) as u64),
537        Slot::NumI64Opt(field) => *field = Some(num(value)?),
538    }
539    Ok(())
540}
541
542/// `unset`: booleans go false, everything else goes back to rmut's
543/// built-in default (an unset field), which is what a muttrc-less
544/// session would have used.
545fn unset(cfg: &mut Config, name: &str) -> Result<(), String> {
546    if name == "from" {
547        cfg.identity.email = None;
548        return Ok(());
549    }
550    match slot(cfg, name).ok_or_else(|| unknown(name))? {
551        Slot::Text(field) => *field = None,
552        Slot::Flag(field) => *field = false,
553        Slot::FlagOpt(field) => *field = Some(false),
554        Slot::Num16(field) => *field = 0,
555        Slot::NumUsize(field) => *field = 0,
556        Slot::NumU64(field) => *field = 0,
557        Slot::NumU64Opt(field) => *field = None,
558        Slot::NumI64Opt(field) => *field = None,
559    }
560    Ok(())
561}
562
563fn toggle(cfg: &mut Config, name: &str) -> Result<(), String> {
564    match slot(cfg, name).ok_or_else(|| unknown(name))? {
565        Slot::Flag(field) => *field = !*field,
566        Slot::FlagOpt(field) => *field = Some(!field.unwrap_or(false)),
567        _ => return Err(format!("{name} is not a boolean")),
568    }
569    Ok(())
570}
571
572fn query(cfg: &mut Config, name: &str) -> Result<String, String> {
573    if name == "from" {
574        let value = cfg.identity.email.clone().unwrap_or_default();
575        return Ok(format!("from=\"{value}\""));
576    }
577    let text = match slot(cfg, name).ok_or_else(|| unknown(name))? {
578        Slot::Text(field) => format!("{name}=\"{}\"", field.clone().unwrap_or_default()),
579        Slot::Flag(field) => flag_text(name, *field),
580        Slot::FlagOpt(field) => flag_text(name, field.unwrap_or(false)),
581        Slot::Num16(field) => format!("{name}={field}"),
582        Slot::NumUsize(field) => format!("{name}={field}"),
583        Slot::NumU64(field) => format!("{name}={field}"),
584        Slot::NumU64Opt(field) => match field {
585            Some(n) => format!("{name}={n}"),
586            None => format!("{name} is unset"),
587        },
588        Slot::NumI64Opt(field) => match field {
589            Some(n) => format!("{name}={n}"),
590            None => format!("{name} is unset"),
591        },
592    };
593    Ok(text)
594}
595
596fn flag_text(name: &str, on: bool) -> String {
597    if on {
598        name.to_string()
599    } else {
600        format!("no{name}")
601    }
602}
603
604/// `ignore`/`unignore`: rmut keeps both lists, so each command adds to
605/// its own and drops the name from the other (mutt's unignore is the
606/// same undo, expressed against one list).
607fn edit_header_list(cfg: &mut Config, headers: &[String], ignoring: bool) {
608    let clean: Vec<String> = headers
609        .iter()
610        .map(|h| h.trim_end_matches(':').to_lowercase())
611        .collect();
612    let (add, remove) = if ignoring {
613        (&mut cfg.pager.ignore, &mut cfg.pager.unignore)
614    } else {
615        (&mut cfg.pager.unignore, &mut cfg.pager.ignore)
616    };
617    let list = add.get_or_insert_with(Vec::new);
618    for name in &clean {
619        if !list.contains(name) {
620            list.push(name.clone());
621        }
622    }
623    if let Some(list) = remove {
624        list.retain(|n| !clean.contains(n));
625    }
626}
627
628/// `color OBJECT FG BG [PATTERN]`, with the importer's slot mapping:
629/// named slots land in `[colors]`, index patterns become
630/// `[[color_index]]` rules, and body regexes `[[color_body]]`.
631fn color(
632    cfg: &mut Config,
633    object: &str,
634    fg: &str,
635    bg: &str,
636    pattern: Option<&str>,
637) -> Result<(), String> {
638    let fg = convert_color(fg);
639    let bg = convert_color(bg);
640    // A foreground saying nothing (black/white/default over a colored
641    // background) means the background is the visible color.
642    let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
643        bg.clone()
644    } else {
645        fg.clone()
646    };
647    if let Some(n) = object.strip_prefix("quoted")
648        && let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
649    {
650        let key = if depth == 0 {
651            "quoted".to_string()
652        } else {
653            format!("quoted{depth}")
654        };
655        cfg.colors.insert(key, vivid);
656        return Ok(());
657    }
658    let mut named = |key: &str, value: String| {
659        cfg.colors.insert(key.to_string(), value);
660    };
661    match (object, pattern) {
662        ("status", _) => {
663            named("status_fg", fg);
664            named("status_bg", bg);
665        }
666        ("search", _) => {
667            if fg != "default" {
668                named("search_fg", fg);
669            }
670            if bg != "default" {
671                named("search_bg", bg);
672            }
673        }
674        ("header" | "hdrdefault", _) => named("header", fg),
675        ("error", _) => named("error", vivid),
676        // One colour goes to the built-in slot; a line that paints a
677        // background as well keeps both, as a [[color_index]] rule.
678        ("index", Some("~D")) if bg == "default" => named("deleted", vivid),
679        ("index", Some("~F")) if bg == "default" => named("flagged", vivid),
680        ("index", Some("~T")) if bg == "default" => named("tagged", vivid),
681        ("index", Some(pattern)) => {
682            crate::pattern::parse(pattern).map_err(|err| format!("bad pattern: {err}"))?;
683            cfg.color_index.push(rule(pattern, fg, bg));
684        }
685        ("body", Some(pattern)) => {
686            regex_lite::Regex::new(pattern).map_err(|err| format!("bad regex: {err}"))?;
687            cfg.color_body.push(rule(pattern, fg, bg));
688        }
689        ("index" | "body", None) => return Err(format!("color {object} needs a pattern")),
690        _ => return Err(format!("no rmut color slot for {object:?}")),
691    }
692    Ok(())
693}
694
695fn rule(pattern: &str, fg: String, bg: String) -> ColorRule {
696    ColorRule {
697        pattern: pattern.to_string(),
698        fg: (fg != "default").then_some(fg),
699        bg: (bg != "default").then_some(bg),
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    fn one(line: &str) -> Command {
708        let mut cmds = parse(line).expect("parses");
709        assert_eq!(cmds.len(), 1, "{line:?} yielded {cmds:?}");
710        cmds.pop().unwrap()
711    }
712
713    #[test]
714    fn alternates_and_my_hdr_at_the_prompt() {
715        let mut cfg = Config::default();
716        for line in [
717            "alternates jane@old\\.example\\.com typo@x",
718            "unalternates typo@x",
719            "my_hdr Organization: Acme",
720            "my_hdr X-Mailer: rmut",
721            "my_hdr Organization: Acme Ltd",
722            "unmy_hdr X-Mailer",
723            "set metoo",
724        ] {
725            for cmd in parse(line).expect(line) {
726                apply(&mut cfg, &cmd).expect(line);
727            }
728        }
729        assert_eq!(cfg.mail.alternates, ["jane@old\\.example\\.com"]);
730        assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
731        assert!(cfg.mail.metoo);
732        for cmd in parse("unmy_hdr *").unwrap() {
733            apply(&mut cfg, &cmd).unwrap();
734        }
735        assert!(cfg.mail.my_hdr.is_empty());
736        assert!(parse("my_hdr Organization").is_err());
737        assert!(parse("alternates").is_err());
738    }
739
740    #[test]
741    fn auto_view_and_alternative_order_at_the_prompt() {
742        let mut cfg = Config::default();
743        for line in [
744            "auto_view text/html Application/Zip",
745            "auto_view text/calendar",
746            "unauto_view text/calendar",
747            "alternative_order text/plain TEXT/HTML",
748            "alternative_order text/plain",
749            "unalternative_order text/html",
750        ] {
751            for cmd in parse(line).expect(line) {
752                apply(&mut cfg, &cmd).expect(line);
753            }
754        }
755        // auto_view leaves the command to mailcap: an empty value.
756        let mut types: Vec<&str> = cfg.filters.keys().map(String::as_str).collect();
757        types.sort_unstable();
758        assert_eq!(types, ["application/zip", "text/html"]);
759        assert!(cfg.filters.values().all(String::is_empty));
760        // A command set by hand survives a later auto_view for the type.
761        cfg.filters
762            .insert("text/html".into(), "w3m -dump -T text/html".into());
763        for cmd in parse("auto_view text/html").unwrap() {
764            apply(&mut cfg, &cmd).unwrap();
765        }
766        assert_eq!(
767            cfg.filters.get("text/html").map(String::as_str),
768            Some("w3m -dump -T text/html")
769        );
770        // The order keeps its first mention and drops duplicates.
771        assert_eq!(cfg.pager.alternative_order, ["text/plain"]);
772        for cmd in parse("unauto_view *").unwrap() {
773            apply(&mut cfg, &cmd).unwrap();
774        }
775        assert!(cfg.filters.is_empty());
776        assert!(parse("auto_view").is_err());
777        assert!(parse("alternative_order").is_err());
778    }
779
780    #[test]
781    fn set_forms() {
782        assert_eq!(
783            one("set index_format=\"%s %f\""),
784            Command::Set {
785                name: "index_format".into(),
786                value: "%s %f".into()
787            }
788        );
789        assert_eq!(one("set nobeep"), Command::Unset("beep".into()));
790        assert_eq!(
791            one("set beep"),
792            Command::Set {
793                name: "beep".into(),
794                value: "yes".into()
795            }
796        );
797        assert_eq!(one("set invbeep"), Command::Toggle("beep".into()));
798        assert_eq!(one("set beep?"), Command::Query("beep".into()));
799        assert_eq!(one("toggle tilde"), Command::Toggle("tilde".into()));
800        assert_eq!(one("unset tilde"), Command::Unset("tilde".into()));
801        assert_eq!(parse("set a=1 b=2").unwrap().len(), 2);
802        assert_eq!(parse("").unwrap(), vec![]);
803        assert_eq!(parse("# comment").unwrap(), vec![]);
804    }
805
806    #[test]
807    fn unknown_command_and_option_report() {
808        assert!(parse("frobnicate x").is_err());
809        let mut cfg = Config::default();
810        let err = apply(&mut cfg, &one("set nosuchthing=1")).unwrap_err();
811        assert!(err.contains("nosuchthing"), "{err}");
812    }
813
814    #[test]
815    fn set_applies_to_config() {
816        let mut cfg = Config::default();
817        apply(&mut cfg, &one("set pager_index_lines=5")).unwrap();
818        assert_eq!(cfg.pager.index_lines, 5);
819        apply(&mut cfg, &one("set wrap=-8")).unwrap();
820        assert_eq!(cfg.pager.wrap, Some(-8));
821        apply(&mut cfg, &one("set tilde")).unwrap();
822        assert!(cfg.pager.tilde);
823        apply(&mut cfg, &one("toggle tilde")).unwrap();
824        assert!(!cfg.pager.tilde);
825        apply(&mut cfg, &one("set from=\"Jane Doe <jane@x>\"")).unwrap();
826        assert_eq!(cfg.identity.email.as_deref(), Some("jane@x"));
827        assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
828        // Numbers and sort orders are checked, not silently accepted.
829        assert!(apply(&mut cfg, &one("set pager_index_lines=x")).is_err());
830        assert!(apply(&mut cfg, &one("set sort=nonsense")).is_err());
831        apply(&mut cfg, &one("set sort=reverse-date")).unwrap();
832        assert_eq!(cfg.index.sort.as_deref(), Some("reverse-date"));
833    }
834
835    #[test]
836    fn query_reports_current_value() {
837        let mut cfg = Config::default();
838        cfg.index.format = Some("%s".into());
839        let out = apply(&mut cfg, &one("set index_format?")).unwrap();
840        assert_eq!(out.as_deref(), Some("index_format=\"%s\""));
841        let out = apply(&mut cfg, &one("set beep?")).unwrap();
842        assert_eq!(out.as_deref(), Some("beep"));
843        apply(&mut cfg, &one("unset beep")).unwrap();
844        let out = apply(&mut cfg, &one("set beep?")).unwrap();
845        assert_eq!(out.as_deref(), Some("nobeep"));
846    }
847
848    #[test]
849    fn ignore_lists_move_names_between_them() {
850        let mut cfg = Config::default();
851        apply(&mut cfg, &one("ignore x-spam: received")).unwrap();
852        assert_eq!(
853            cfg.pager.ignore.as_deref(),
854            Some(["x-spam".to_string(), "received".to_string()].as_slice())
855        );
856        apply(&mut cfg, &one("unignore received")).unwrap();
857        assert_eq!(
858            cfg.pager.ignore.as_deref(),
859            Some(["x-spam".to_string()].as_slice())
860        );
861        assert_eq!(
862            cfg.pager.unignore.as_deref(),
863            Some(["received".to_string()].as_slice())
864        );
865    }
866
867    #[test]
868    fn colors_land_in_their_slots() {
869        let mut cfg = Config::default();
870        apply(&mut cfg, &one("color status brightwhite blue")).unwrap();
871        assert_eq!(
872            cfg.colors.get("status_fg").map(String::as_str),
873            Some("lightwhite")
874        );
875        assert_eq!(
876            cfg.colors.get("status_bg").map(String::as_str),
877            Some("blue")
878        );
879        apply(&mut cfg, &one("color quoted2 green default")).unwrap();
880        assert_eq!(cfg.colors.get("quoted2").map(String::as_str), Some("green"));
881        apply(&mut cfg, &one("color index red default ~F")).unwrap();
882        assert_eq!(cfg.colors.get("flagged").map(String::as_str), Some("red"));
883        apply(&mut cfg, &one("color index red default ~f jane")).unwrap();
884        assert_eq!(cfg.color_index.len(), 1);
885        apply(&mut cfg, &one("color body yellow default TODO")).unwrap();
886        assert_eq!(cfg.color_body.len(), 1);
887        // A pattern or regex that does not compile is refused.
888        assert!(apply(&mut cfg, &one("color index red default ~q")).is_err());
889        assert!(apply(&mut cfg, &one("color body red default [")).is_err());
890        assert!(apply(&mut cfg, &one("color nosuchslot red default")).is_err());
891    }
892
893    #[test]
894    fn bind_macro_and_push_parse_but_stay_for_the_caller() {
895        assert_eq!(
896            one("bind index \\Cd delete-message"),
897            Command::Bind {
898                menu: Menu::Index,
899                key: "\\Cd".into(),
900                function: "delete-message".into()
901            }
902        );
903        assert_eq!(parse("bind index,pager d delete-message").unwrap().len(), 2);
904        assert!(parse("bind nosuchmenu d delete").is_err());
905        assert_eq!(
906            one("macro index L \"l~f jane<enter>\""),
907            Command::Macro {
908                menu: Menu::Index,
909                key: "L".into(),
910                seq: "l~f jane<enter>".into()
911            }
912        );
913        assert_eq!(one("push \"<enter>\""), Command::Push("<enter>".into()));
914        assert_eq!(one("exec sync"), Command::Exec("sync".into()));
915        assert_eq!(
916            one("alias jane Jane Doe <jane@x>"),
917            Command::Alias {
918                nick: "jane".into(),
919                expansion: "Jane Doe <jane@x>".into()
920            }
921        );
922        // The config half leaves them alone.
923        let mut cfg = Config::default();
924        assert_eq!(apply(&mut cfg, &one("exec sync")).unwrap(), None);
925    }
926}