Skip to main content

rmut_core/
muttrc.rs

1//! Muttrc importer: translate the common muttrc directives into rmut's
2//! TOML config. Meant for a one-time `rmut --import-muttrc` run whose
3//! output the user reviews and saves; everything that does not map is
4//! kept visible as `# not imported:` comments, never dropped silently.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10
11/// Parsed import: the TOML text plus the alias lines found, in
12/// muttrc form. rmut reads mutt-format alias files as they are, so
13/// these belong in one of those rather than in the TOML; the caller
14/// either writes them to the alias file or shows them with
15/// `alias_block`.
16pub struct Import {
17    pub toml: String,
18    pub aliases: Vec<String>,
19    /// mutt's $alias_file, when the muttrc named one: rmut reads and
20    /// appends to it, so an import writes no alias file of its own.
21    pub alias_file: Option<String>,
22}
23
24pub fn import_file(path: &Path) -> Result<Import> {
25    let text =
26        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
27    let dir = path.parent().unwrap_or(Path::new("."));
28    Ok(import(&text, dir))
29}
30
31/// `dir` anchors relative `source` includes.
32pub fn import(text: &str, dir: &Path) -> Import {
33    let mut st = State::default();
34    parse_into(text, dir, 0, &mut st);
35    Import {
36        toml: st.to_toml(),
37        aliases: st.aliases.clone(),
38        alias_file: st.alias_file.clone(),
39    }
40}
41
42/// An `[[identities]]` rule from a folder-hook / send-hook.
43struct IdRule {
44    folder: Option<String>,
45    recipient: Option<String>,
46    name: Option<String>,
47    email: Option<String>,
48}
49
50#[derive(Default)]
51struct State {
52    name: Option<String>,
53    email: Option<String>,
54    reverse_name: bool,
55    /// `set noreverse_realname`: keep the configured name.
56    no_reverse_realname: bool,
57    identity_rules: Vec<IdRule>,
58    /// folder-hooks whose command is not a from/realname set: they
59    /// become [[folder_hooks]] with the command line kept whole.
60    folder_hook_lines: Vec<(String, String)>,
61    /// message-hook / reply-hook: (mutt pattern, command line).
62    message_hooks: Vec<(String, String)>,
63    reply_hooks: Vec<(String, String)>,
64    /// fcc-hook / fcc-save-hook: (mutt pattern, mailbox).
65    fcc_hooks: Vec<(String, String)>,
66    /// crypt-hook: (address pattern, key id).
67    crypt_hooks: Vec<(String, String)>,
68    /// `set folder`, for expanding the +/= mailbox shortcuts.
69    folder: Option<String>,
70    spoolfile: Option<String>,
71    mailboxes: Vec<String>,
72    sent: Option<String>,
73    postponed: Option<String>,
74    sendmail: Option<String>,
75    editor: Option<String>,
76    poll_seconds: Option<u64>,
77    index_format: Option<String>,
78    colors: BTreeMap<&'static str, String>,
79    /// `color quoted`/`quotedN` depth palette, keyed by N.
80    quoted_colors: BTreeMap<usize, String>,
81    /// `color index FG BG PATTERN` rules, in muttrc order.
82    color_index_rules: Vec<(String, String, String)>,
83    /// `color body FG BG REGEX` rules, in muttrc order.
84    color_body_rules: Vec<(String, String, String)>,
85    quote_regexp: Option<String>,
86    /// ignore/unignore/hdr_order lists for the brief header view.
87    hdr_ignore: Vec<String>,
88    /// mutt's `lists` and `subscribe`, kept apart: a subscribed list
89    /// is also a known list, but only it drops my address from
90    /// Mail-Followup-To.
91    lists: Vec<String>,
92    subscribed: Vec<String>,
93    /// mutt's `alternates`: regexes for my other addresses.
94    alternates: Vec<String>,
95    /// mutt's `my_hdr`, as whole "Name: value" lines, in file order.
96    my_hdr: Vec<String>,
97    /// `set metoo`: keep my address in a group reply.
98    metoo: bool,
99    /// `set forward_quote`: the forwarded text comes in quoted.
100    forward_quote: bool,
101    /// `set signature` and `set nosig_dashes`.
102    signature: Option<String>,
103    no_sig_dashes: bool,
104    sig_on_top: bool,
105    hostname: Option<String>,
106    user_agent: bool,
107    /// mutt's $abort_nosubject and $abort_unmodified, when they are
108    /// not what rmut does anyway.
109    abort_nosubject: Option<String>,
110    no_abort_unmodified: bool,
111    /// `set text_flowed`: send text/plain; format=flowed.
112    text_flowed: bool,
113    /// mutt's $delete quadoption, when it is not the ask default.
114    delete: Option<String>,
115    /// neomutt's attachment reminder: $abort_noattach (as
116    /// no/ask/yes) and $abort_noattach_regex.
117    abort_noattach: Option<String>,
118    attach_keyword: Option<String>,
119    /// `set noreflow_text`: leave a flowed part's line breaks alone.
120    no_reflow_text: bool,
121    /// `alternative_order`: preferred types in a multipart/alternative.
122    alternative_order: Vec<String>,
123    hdr_unignore: Vec<String>,
124    hdr_order: Vec<String>,
125    pager_format: Option<String>,
126    wrap: Option<i64>,
127    tilde: bool,
128    status_format: Option<String>,
129    title_format: Option<String>,
130    set_title: bool,
131    history_file: Option<String>,
132    status_on_top: bool,
133    arrow_cursor: bool,
134    status_chars: Option<String>,
135    no_beep: bool,
136    /// `set beep_new`: ring for an arrival as well.
137    beep_new: bool,
138    /// `set nowait_key`: no pause after a shell escape.
139    no_wait_key: bool,
140    /// `set nomark_old`: unread mail stays new when you leave.
141    no_mark_old: bool,
142    /// The R85 marks: $delete_untag off, $flag_safe, $maildir_trash,
143    /// $mail_check_recent off, $check_new off.
144    no_delete_untag: bool,
145    flag_safe: bool,
146    maildir_trash: bool,
147    no_mail_check_recent: bool,
148    no_check_new: bool,
149    /// `set nouncollapse_new`: a folded thread stays folded when it
150    /// grows.
151    no_uncollapse_new: bool,
152    /// The menu knobs: $menu_scroll off, $menu_context, $menu_move_off
153    /// off, and $help off.
154    no_menu_scroll: bool,
155    menu_context: Option<usize>,
156    no_menu_move_off: bool,
157    no_help: bool,
158    /// R87: the browser and alias orders, $shell and $tmpdir.
159    sort_browser: Option<String>,
160    sort_alias: Option<String>,
161    shell: Option<String>,
162    tmpdir: Option<String>,
163    ispell: Option<String>,
164    /// mutt's $print quadoption, when it is not rmut's ask-no.
165    print_confirm: Option<String>,
166    /// mutt's leaving and filing habits.
167    quit: Option<String>,
168    postpone: Option<String>,
169    recall: Option<String>,
170    confirmappend: bool,
171    save_name: bool,
172    force_name: bool,
173    /// mutt's reading habits: $pager_stop, and the three that are on
174    /// by default, kept as Option so "yes" stays satisfied.
175    pager_stop: bool,
176    markers_off: bool,
177    smart_wrap_off: bool,
178    collapse_unread_off: bool,
179    uncollapse_jump: bool,
180    hide_thread_subject: bool,
181    /// mutt's $attribution, $indent_string and $forward_format, which
182    /// rmut takes as they are: the specifiers are the same.
183    attribution: Option<String>,
184    indent_string: Option<String>,
185    reply_regexp: Option<String>,
186    simple_search: Option<String>,
187    no_wrap_search: bool,
188    forward_format: Option<String>,
189    /// mutt's $include, $askcc and $askbcc.
190    include: Option<String>,
191    ask_cc: bool,
192    ask_bcc: bool,
193    /// mutt's $connect_timeout, in seconds.
194    connect_timeout: Option<u64>,
195    /// mutt's $certificate_file, a PEM of extra roots; and
196    /// $ssl_usesystemcerts = no, which turns the OS store off.
197    certificate_file: Option<String>,
198    no_system_cas: bool,
199    keys_index: BTreeMap<&'static str, String>,
200    keys_pager: BTreeMap<&'static str, String>,
201    macros_index: BTreeMap<String, String>,
202    macros_pager: BTreeMap<String, String>,
203    sign_key: Option<String>,
204    sign_by_default: bool,
205    encrypt_by_default: bool,
206    reply_sign: bool,
207    reply_encrypt: bool,
208    reply_sign_encrypted: bool,
209    print: Option<String>,
210    query_command: Option<String>,
211    trash: Option<String>,
212    /// `set edit_headers` (off is mutt's and rmut's default).
213    edit_headers_on: bool,
214    sort: Option<String>,
215    sort_aux: Option<String>,
216    date_format: Option<String>,
217    pager_index_lines: Option<u64>,
218    pager_context: Option<u64>,
219    search_context: Option<u64>,
220    forward_attach: bool,
221    /// mime_forward = ask-yes/ask-no.
222    forward_ask: bool,
223    fast_reply: bool,
224    autoedit: bool,
225    /// `set nocopy`: skip the sent copy.
226    no_copy: bool,
227    new_mail_command: Option<String>,
228    save_default: Option<String>,
229    filters: BTreeMap<String, String>,
230    imap_user: Option<String>,
231    imap_pass: Option<String>,
232    /// "xoauth2"/"oauthbearer" from *_authenticators.
233    oauth: Option<String>,
234    smtp_pass: Option<String>,
235    smtp_url: Option<String>,
236    aliases: Vec<String>,
237    skipped: Vec<String>,
238    /// Directives that match what rmut always does, acknowledged in
239    /// the output so the user knows they were seen, not dropped.
240    satisfied: Vec<String>,
241    /// Directives rmut answers in its own way: nothing to set, but
242    /// worth saying how, since "not imported" would read as a hole.
243    differs: Vec<String>,
244    sidebar_visible: bool,
245    sidebar_width: Option<u16>,
246    alias_file: Option<String>,
247}
248
249fn parse_into(text: &str, dir: &Path, depth: usize, st: &mut State) {
250    for line in logical_lines(text) {
251        let tokens = tokenize(&line);
252        let Some(cmd) = tokens.first() else {
253            continue;
254        };
255        match cmd.as_str() {
256            "set" => {
257                for (name, value) in assignments(&tokens[1..]) {
258                    st.set(&name, &value, &line);
259                }
260            }
261            "mailboxes" => {
262                for t in &tokens[1..] {
263                    let m = st.expand_mailbox(t);
264                    if !st.mailboxes.contains(&m) {
265                        st.mailboxes.push(m);
266                    }
267                }
268            }
269            "alias" => st.aliases.push(line.clone()),
270            "lists" => {
271                for t in &tokens[1..] {
272                    if !st.lists.contains(t) {
273                        st.lists.push(t.clone());
274                    }
275                }
276            }
277            "subscribe" => {
278                for t in &tokens[1..] {
279                    if !st.subscribed.contains(t) {
280                        st.subscribed.push(t.clone());
281                    }
282                }
283            }
284            "unlists" | "unsubscribe" => {
285                for t in &tokens[1..] {
286                    st.lists.retain(|l| l != t);
287                    st.subscribed.retain(|l| l != t);
288                }
289            }
290            "alternates" => {
291                for t in &tokens[1..] {
292                    if !st.alternates.contains(t) {
293                        st.alternates.push(t.clone());
294                    }
295                }
296            }
297            "unalternates" => {
298                for t in &tokens[1..] {
299                    if t == "*" {
300                        st.alternates.clear();
301                    } else {
302                        st.alternates.retain(|a| a != t);
303                    }
304                }
305            }
306            // The value carries colons and spaces, so it is taken off
307            // the raw line rather than from the tokens.
308            "my_hdr" => {
309                let rest = line["my_hdr".len()..].trim().trim_matches('"').to_string();
310                if rest.contains(':') {
311                    st.set_my_hdr(rest);
312                } else {
313                    st.skip(&line, "my_hdr needs a \"Name: value\" header line");
314                }
315            }
316            "unmy_hdr" => {
317                for t in &tokens[1..] {
318                    if t == "*" {
319                        st.my_hdr.clear();
320                    } else {
321                        let name = t.trim_end_matches(':');
322                        st.my_hdr.retain(|h| !header_named(h, name));
323                    }
324                }
325            }
326            "ignore" => st.hdr_ignore.extend(tokens[1..].iter().cloned()),
327            "unignore" => st.hdr_unignore.extend(tokens[1..].iter().cloned()),
328            "hdr_order" => st.hdr_order.extend(
329                tokens[1..]
330                    .iter()
331                    .map(|t| t.trim_end_matches(':').to_string()),
332            ),
333            "bind" => st.bind(&tokens[1..], &line),
334            "macro" => st.mutt_macro(&tokens[1..], &line),
335            "color" => st.color(&tokens[1..], &line),
336            "auto_view" | "unauto_view" => {
337                for mime in &tokens[1..] {
338                    st.auto_view(cmd == "auto_view", mime, &line);
339                }
340            }
341            "alternative_order" | "unalternative_order" => {
342                for mime in &tokens[1..] {
343                    let mime = mime.to_lowercase();
344                    if cmd == "alternative_order" {
345                        if !st.alternative_order.contains(&mime) {
346                            st.alternative_order.push(mime);
347                        }
348                    } else if mime == "*" {
349                        st.alternative_order.clear();
350                    } else {
351                        st.alternative_order.retain(|t| *t != mime);
352                    }
353                }
354            }
355            "folder-hook" | "send-hook" => {
356                let folder_hook = cmd == "folder-hook";
357                st.hook(folder_hook, &tokens[1..], &line);
358            }
359            "message-hook" | "reply-hook" => st.command_hook(cmd, &tokens[1..], &line),
360            "fcc-hook" | "fcc-save-hook" => st.fcc_hook(cmd, &tokens[1..], &line),
361            "crypt-hook" | "pgp-hook" => match &tokens[1..] {
362                [address, key] => st.crypt_hooks.push((address.clone(), key.clone())),
363                _ => st.skip(&line, "crypt-hook ADDRESS KEYID"),
364            },
365            "save-hook" => match (tokens.get(1).map(String::as_str), tokens.get(2)) {
366                // Expanded at output time; $folder may come later.
367                (Some("." | "~A"), Some(mailbox)) => st.save_default = Some(mailbox.clone()),
368                _ => st.skip(&line, "only the catch-all pattern . maps to [mail] save"),
369            },
370            "source" if tokens.len() >= 2 => {
371                if depth >= 10 {
372                    st.skip(&line, "source nesting too deep");
373                    continue;
374                }
375                let target = expand_path(&tokens[1], dir);
376                match std::fs::read_to_string(&target) {
377                    Ok(included) => {
378                        let sub = target.parent().unwrap_or(dir).to_path_buf();
379                        parse_into(&included, &sub, depth + 1, st);
380                    }
381                    Err(err) => st.skip(&line, &format!("cannot read: {err}")),
382                }
383            }
384            _ => st.skip(&line, "no rmut equivalent"),
385        }
386    }
387}
388
389// ---- muttrc syntax ----
390
391/// Comment-stripped, continuation-joined, non-empty lines.
392fn logical_lines(text: &str) -> Vec<String> {
393    let mut out = Vec::new();
394    let mut pending = String::new();
395    for raw in text.lines() {
396        pending.push_str(raw);
397        if pending.ends_with('\\') {
398            pending.pop();
399            continue;
400        }
401        let line = strip_comment(&pending);
402        if !line.trim().is_empty() {
403            out.push(line.trim().to_string());
404        }
405        pending.clear();
406    }
407    if !pending.trim().is_empty() {
408        out.push(strip_comment(&pending).trim().to_string());
409    }
410    out.retain(|l| !l.is_empty());
411    out
412}
413
414/// Cut an unquoted `#` comment.
415fn strip_comment(line: &str) -> String {
416    let mut quote = None;
417    for (i, c) in line.char_indices() {
418        match (quote, c) {
419            (None, '#') => return line[..i].to_string(),
420            (None, '\'' | '"') => quote = Some(c),
421            (Some(q), c) if c == q => quote = None,
422            _ => {}
423        }
424    }
425    line.to_string()
426}
427
428/// Whitespace-separated tokens; quotes group, backslash escapes inside
429/// double quotes and bare text (mutt-ish, close enough for configs).
430pub(crate) fn tokenize(line: &str) -> Vec<String> {
431    let mut out = Vec::new();
432    let mut cur = String::new();
433    let mut has = false;
434    let mut quote: Option<char> = None;
435    let mut chars = line.chars();
436    while let Some(c) = chars.next() {
437        match (quote, c) {
438            (Some('\''), '\'') | (Some('"'), '"') => quote = None,
439            (Some(_), c) => cur.push(c),
440            (None, '\'' | '"') => {
441                quote = Some(c);
442                has = true;
443            }
444            (None, '\\') => {
445                if let Some(next) = chars.next() {
446                    cur.push('\\');
447                    cur.push(next);
448                    has = true;
449                }
450            }
451            (None, c) if c.is_whitespace() => {
452                if has || !cur.is_empty() {
453                    out.push(std::mem::take(&mut cur));
454                    has = false;
455                }
456            }
457            (None, c) => {
458                cur.push(c);
459                has = true;
460            }
461        }
462    }
463    if has || !cur.is_empty() {
464        out.push(cur);
465    }
466    out
467}
468
469/// `set` arguments as (name, value) pairs: `a=b`, `a = b`, `a =b`,
470/// `a= b`, and bare booleans `a` / `noa`.
471pub(crate) fn assignments(tokens: &[String]) -> Vec<(String, String)> {
472    let mut out = Vec::new();
473    let mut i = 0;
474    while i < tokens.len() {
475        let t = &tokens[i];
476        if let Some((name, value)) = t.split_once('=') {
477            if !name.is_empty() && !value.is_empty() {
478                out.push((name.to_string(), value.to_string()));
479                i += 1;
480            } else if !name.is_empty() {
481                // "name=" with the value in the next token.
482                out.push((
483                    name.to_string(),
484                    tokens.get(i + 1).cloned().unwrap_or_default(),
485                ));
486                i += 2;
487            } else {
488                i += 1;
489            }
490        } else if tokens.get(i + 1).map(String::as_str) == Some("=") {
491            out.push((t.clone(), tokens.get(i + 2).cloned().unwrap_or_default()));
492            i += 3;
493        } else if let Some(v) = tokens.get(i + 1).and_then(|n| n.strip_prefix('=')) {
494            out.push((t.clone(), v.to_string()));
495            i += 2;
496        } else if let Some(name) = t.strip_prefix("no") {
497            out.push((name.to_string(), "no".into()));
498            i += 1;
499        } else {
500            out.push((t.clone(), "yes".into()));
501            i += 1;
502        }
503    }
504    out
505}
506
507pub(crate) fn is_yes(value: &str) -> bool {
508    matches!(value, "yes" | "ask-yes" | "true" | "1")
509}
510
511/// "Jane Doe <jane@x>" or a bare address → (display name, address).
512pub(crate) fn split_from(v: &str) -> (Option<String>, String) {
513    match v.split_once('<') {
514        Some((n, rest)) => {
515            let n = n.trim().trim_matches('"');
516            (
517                (!n.is_empty()).then(|| n.to_string()),
518                rest.trim_end_matches('>').trim().to_string(),
519            )
520        }
521        None => (None, v.trim().to_string()),
522    }
523}
524
525/// A mutt hook regex as an rmut glob, for the easy shapes: literals,
526/// `.*` runs, `^`/`$` anchors, `\`-escapes, a `~t`/`~C` recipient
527/// prefix, `+`/`=` folder shortcuts. None means too regex-y.
528fn hook_glob(pattern: &str) -> Option<String> {
529    let p = pattern.trim();
530    let p = p
531        .strip_prefix("~t ")
532        .or_else(|| p.strip_prefix("~C "))
533        .unwrap_or(p)
534        .trim()
535        .trim_start_matches(['+', '=']);
536    if p.starts_with('~') || p.starts_with('%') {
537        return None;
538    }
539    if p == "." || p == ".*" {
540        return Some("*".into());
541    }
542    let (p, anchored_start) = match p.strip_prefix('^') {
543        Some(rest) => (rest, true),
544        None => (p, false),
545    };
546    let (p, anchored_end) = match p.strip_suffix('$') {
547        Some(rest) => (rest, true),
548        None => (p, false),
549    };
550    let mut glob = String::new();
551    let mut chars = p.chars().peekable();
552    while let Some(c) = chars.next() {
553        match c {
554            '\\' => glob.push(chars.next()?),
555            '.' if chars.peek() == Some(&'*') => {
556                chars.next();
557                glob.push('*');
558            }
559            '(' | ')' | '[' | ']' | '{' | '}' | '|' | '+' | '?' | '$' | '^' | '*' => return None,
560            c => glob.push(c),
561        }
562    }
563    if !anchored_start && !glob.starts_with('*') {
564        glob.insert(0, '*');
565    }
566    if !anchored_end && !glob.ends_with('*') {
567        glob.push('*');
568    }
569    Some(glob)
570}
571
572/// mutt's $default_hook, "~f %s !~P | (~P ~C %s)": a hook pattern that
573/// is a plain address means "from them, unless I sent it, in which
574/// case addressed to them". Patterns that already name an operator
575/// (or are the catch-all) are left alone.
576/// mutt's regexes spell a word edge \\< and \\>; rmut's engine
577/// spells it \\b. A quoted muttrc value keeps its doubled
578/// backslashes, so both forms are translated.
579fn word_boundaries(re: &str) -> String {
580    re.replace(r"\\<", r"\b")
581        .replace(r"\\>", r"\b")
582        .replace(r"\<", r"\b")
583        .replace(r"\>", r"\b")
584}
585
586/// The alias lines as a comment block, for the printed-for-review
587/// form of an import, which writes nothing anywhere.
588pub fn alias_block(aliases: &[String], target: &std::path::Path) -> String {
589    if aliases.is_empty() {
590        return String::new();
591    }
592    let mut out = format!(
593        "\n# aliases found: rmut reads mutt-format alias files; put these\n\
594         # lines in {} (or point $RMUT_ALIASES at them):\n",
595        target.display()
596    );
597    for a in aliases {
598        out += &format!("#   {a}\n");
599    }
600    out
601}
602
603fn default_hook_pattern(pattern: &str) -> String {
604    let p = pattern.trim();
605    if p == "." || p == ".*" {
606        return "~A".into();
607    }
608    if p.starts_with('~') || p.starts_with('!') || p.starts_with('(') || p.contains(" ~") {
609        return p.to_string();
610    }
611    format!("(~f \"{p}\" !~P) | (~P ~C \"{p}\")")
612}
613
614fn expand_path(value: &str, dir: &Path) -> PathBuf {
615    if let Some(rest) = value.strip_prefix("~/")
616        && let Ok(home) = std::env::var("HOME")
617    {
618        return PathBuf::from(home).join(rest);
619    }
620    let p = PathBuf::from(value);
621    if p.is_relative() { dir.join(p) } else { p }
622}
623
624// ---- directive handling ----
625
626/// Account name used when the muttrc points at an IMAP server.
627const ACCOUNT: &str = "mutt";
628
629/// True when a stored `my_hdr` line carries this header name.
630fn header_named(entry: &str, name: &str) -> bool {
631    entry
632        .split_once(':')
633        .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
634}
635
636impl State {
637    fn skip(&mut self, line: &str, why: &str) {
638        self.skipped.push(format!("{line}  ({why})"));
639    }
640
641    /// mutt keeps one my_hdr per header name: a later line for the
642    /// same header replaces the earlier one.
643    fn set_my_hdr(&mut self, entry: String) {
644        let Some((name, _)) = entry.split_once(':') else {
645            return;
646        };
647        let name = name.trim().to_string();
648        self.my_hdr.retain(|h| !header_named(h, &name));
649        self.my_hdr.push(entry);
650    }
651
652    fn satisfy(&mut self, line: &str, why: &str) {
653        self.satisfied.push(format!("{line}  ({why})"));
654    }
655
656    /// Not a setting rmut has, and not a hole either: rmut does the
657    /// same thing another way, and the report says which.
658    fn differently(&mut self, line: &str, how: &str) {
659        self.differs.push(format!("{line}  ({how})"));
660    }
661
662    fn set(&mut self, name: &str, value: &str, line: &str) {
663        let v = value.to_string();
664        match name {
665            "realname" => self.name = Some(v),
666            "from" => {
667                let (n, e) = split_from(&v);
668                if let Some(n) = n {
669                    self.name.get_or_insert(n);
670                }
671                self.email = Some(e);
672            }
673            "metoo" => {
674                self.metoo = is_yes(value);
675            }
676            "delete" => {
677                // A quadoption: ask-yes and ask-no are rmut's "ask",
678                // which is also its default, so only yes/no are worth
679                // carrying over.
680                match v.as_str() {
681                    "yes" | "no" => self.delete = Some(v),
682                    _ => {}
683                }
684            }
685            "abort_noattach" => {
686                // A quadoption: ask-yes and ask-no both become "ask".
687                self.abort_noattach = Some(
688                    match v.as_str() {
689                        "yes" => "yes",
690                        "no" => "no",
691                        _ => "ask",
692                    }
693                    .to_string(),
694                );
695            }
696            "abort_noattach_regex" => self.attach_keyword = Some(word_boundaries(&v)),
697            "text_flowed" => {
698                self.text_flowed = is_yes(value);
699            }
700            "reflow_text" => {
701                self.no_reflow_text = !is_yes(value);
702            }
703            "reverse_name" => {
704                if is_yes(&v) {
705                    self.reverse_name = true;
706                } else {
707                    self.satisfy(line, "off is rmut's default");
708                }
709            }
710            "folder" => self.folder = Some(v),
711            "spoolfile" => self.spoolfile = Some(v),
712            "record" => self.sent = Some(v),
713            "postponed" => self.postponed = Some(v),
714            "sendmail" => self.sendmail = Some(v),
715            "editor" | "visual" => self.editor = Some(v),
716            "mail_check" => match v.parse() {
717                Ok(n) => self.poll_seconds = Some(n),
718                Err(_) => self.skip(line, "not a number"),
719            },
720            "index_format" => self.index_format = Some(v),
721            "pgp_sign_as" | "pgp_default_key" => self.sign_key = Some(v),
722            "crypt_autosign" | "pgp_autosign" => self.sign_by_default = is_yes(&v),
723            "crypt_autoencrypt" | "pgp_autoencrypt" => self.encrypt_by_default = is_yes(&v),
724            "crypt_replysign" => self.reply_sign = is_yes(&v),
725            "crypt_replyencrypt" => self.reply_encrypt = is_yes(&v),
726            "crypt_replysignencrypted" => self.reply_sign_encrypted = is_yes(&v),
727            "assumed_charset" => self.differently(
728                line,
729                "rmut lets mailparse decode declared charsets; undeclared 8-bit is read as UTF-8",
730            ),
731            "print_command" => self.print = Some(v),
732            "query_command" => self.query_command = Some(v),
733            "trash" => self.trash = Some(v),
734            "edit_headers" => {
735                if is_yes(&v) {
736                    self.edit_headers_on = true;
737                } else {
738                    self.satisfy(line, "off is rmut's default too");
739                }
740            }
741            "status_format" => self.status_format = Some(v),
742            "status_chars" => self.status_chars = Some(v),
743            "ts_status_format" | "ts_icon_format" => self.title_format = Some(v),
744            "history_file" => self.history_file = Some(v),
745            "status_on_top" => {
746                if is_yes(value) {
747                    self.status_on_top = true;
748                } else {
749                    self.satisfy(line, "the status bar sits at the bottom, as rmut has it");
750                }
751            }
752            "arrow_cursor" => {
753                if is_yes(value) {
754                    self.arrow_cursor = true;
755                } else {
756                    self.satisfy(line, "rmut marks the selection with reverse video by default");
757                }
758            }
759            "save_history" | "history" => self.differently(
760                line,
761                "rmut keeps 100 entries per prompt; set [ui] history_file to persist them",
762            ),
763            "ts_enabled" => {
764                if is_yes(value) {
765                    self.set_title = true;
766                } else {
767                    self.satisfy(line, "rmut leaves the terminal title alone by default");
768                }
769            }
770            "imap_user" => self.imap_user = Some(v),
771            "smtp_url" => self.smtp_url = Some(v),
772            "imap_pass" => self.imap_pass = Some(v),
773            "smtp_pass" => self.smtp_pass = Some(v),
774            "certificate_file" | "ssl_ca_certificates_file" => self.certificate_file = Some(v),
775            "ssl_usesystemcerts" => {
776                if is_yes(&v) {
777                    self.satisfy(line, "rmut trusts the OS store by default");
778                } else {
779                    self.no_system_cas = true;
780                }
781            }
782            "ssl_verify_host" | "ssl_verify_dates" => {
783                // rmut always verifies; it has no interactive
784                // accept-once, so a `no` here cannot be honoured.
785                if is_yes(&v) {
786                    self.satisfy(line, "rmut always verifies the certificate");
787                } else {
788                    self.skip(line, "rmut cannot be told to skip verification");
789                }
790            }
791            "tunnel" => self.skip(line, "rmut has no $tunnel transport yet"),
792            "ssl_client_cert" => self.skip(line, "rmut has no client-certificate auth yet"),
793            "ssl_starttls" | "ssl_force_tls" => {
794                if is_yes(&v) {
795                    self.satisfy(line, "rmut always negotiates TLS/STARTTLS");
796                } else {
797                    self.skip(line, "rmut cannot skip TLS (imap_tls = false is for tests)");
798                }
799            }
800            "charset" | "send_charset" => {
801                if v.to_lowercase().replace(['-', '_'], "").contains("utf8") {
802                    self.satisfy(line, "rmut is UTF-8 native");
803                } else {
804                    self.skip(line, "rmut is UTF-8 only");
805                }
806            }
807            "pgp_auto_decode" => {
808                if is_yes(&v) {
809                    self.satisfy(line, "rmut always decrypts/verifies PGP on view");
810                } else {
811                    self.skip(line, "rmut always checks PGP; there is no off switch");
812                }
813            }
814            "sort" => {
815                let (rev, name) = match v.strip_prefix("reverse-") {
816                    Some(rest) => ("reverse-", rest),
817                    None => ("", v.as_str()),
818                };
819                match name {
820                    "date" | "date-sent" | "date-received" => {
821                        self.sort = Some(format!("{rev}date"));
822                    }
823                    "threads" => self.sort = Some("threads".into()),
824                    "subject" | "size" | "from" | "label" => {
825                        self.sort = Some(format!("{rev}{name}"))
826                    }
827                    _ => self.skip(line, "no matching rmut sort order"),
828                }
829            }
830            "sort_aux" => {
831                // rmut takes mutt's spellings as they are: `last-`
832                // orders a thread by its newest message, `reverse-`
833                // turns the threads round.
834                let spec = v.trim().to_lowercase();
835                let bare = spec.strip_prefix("reverse-").unwrap_or(&spec);
836                let bare = bare.strip_prefix("last-").unwrap_or(bare);
837                match bare {
838                    "date" | "date-sent" | "date-received" => {
839                        if spec == "date" {
840                            self.satisfy(line, "threads are ordered oldest-first by default");
841                        } else {
842                            self.sort_aux = Some(spec);
843                        }
844                    }
845                    _ => self.skip(line, "rmut orders threads by date"),
846                }
847            }
848            "date_format" => {
849                // A leading ! toggles the locale in mutt; the format
850                // string itself is what matters here.
851                self.date_format = Some(v.trim_start_matches('!').to_string());
852            }
853            "pager_index_lines" => match v.parse() {
854                Ok(n) => self.pager_index_lines = Some(n),
855                Err(_) => self.skip(line, "not a number"),
856            },
857            "pager_context" => match v.parse() {
858                Ok(n) => self.pager_context = Some(n),
859                Err(_) => self.skip(line, "not a number"),
860            },
861            "search_context" => match v.parse() {
862                Ok(n) => self.search_context = Some(n),
863                Err(_) => self.skip(line, "not a number"),
864            },
865            "quote_regexp" => self.quote_regexp = Some(v.to_string()),
866            "new_mail_command" => self.new_mail_command = Some(v.to_string()),
867            "pager_format" => self.pager_format = Some(v.to_string()),
868            "wrap" => match v.parse() {
869                Ok(n) => self.wrap = Some(n),
870                Err(_) => self.skip(line, "not a number"),
871            },
872            "tilde" => {
873                if is_yes(&v) {
874                    self.tilde = true;
875                } else {
876                    self.satisfy(line, "no tilde padding is rmut's default");
877                }
878            }
879            "mime_forward" => {
880                let m = v.to_lowercase();
881                if m.starts_with("ask") {
882                    self.forward_ask = true;
883                } else if is_yes(&v) {
884                    self.forward_attach = true;
885                } else {
886                    self.satisfy(line, "inline forwarding is rmut's default");
887                }
888            }
889            "connect_timeout" => match v.parse::<i64>() {
890                // mutt waits for the OS when it is zero or negative.
891                Ok(secs) => self.connect_timeout = Some(secs.max(0) as u64),
892                Err(_) => self.skip(line, "connect_timeout wants a number"),
893            },
894            "beep" => {
895                if is_yes(&v) {
896                    self.satisfy(line, "beeping on errors is rmut's default");
897                } else {
898                    self.no_beep = true;
899                }
900            }
901            "beep_new" => {
902                if is_yes(value) {
903                    self.beep_new = true;
904                } else {
905                    self.satisfy(line, "an arrival rings no bell of its own");
906                }
907            }
908            "wait_key" => {
909                if is_yes(value) {
910                    self.satisfy(line, "a shell escape waits for Enter already");
911                } else {
912                    self.no_wait_key = true;
913                }
914            }
915            "mark_old" => {
916                if is_yes(value) {
917                    self.satisfy(line, "unread mail ages to old on the way out, as in mutt");
918                } else {
919                    self.no_mark_old = true;
920                }
921            }
922            "delete_untag" => {
923                if is_yes(value) {
924                    self.satisfy(line, "deleting a tagged message untags it, as in mutt");
925                } else {
926                    self.no_delete_untag = true;
927                }
928            }
929            "flag_safe" => {
930                if is_yes(value) {
931                    self.flag_safe = true;
932                } else {
933                    self.satisfy(line, "flagged messages can be deleted, as in mutt");
934                }
935            }
936            "maildir_trash" => {
937                if is_yes(value) {
938                    self.maildir_trash = true;
939                } else {
940                    self.satisfy(line, "a purge unlinks, as in mutt");
941                }
942            }
943            "keep_flagged" => {
944                self.differently(line, "rmut has no $move: nothing leaves the spool on its own");
945            }
946            "uncollapse_new" => {
947                if is_yes(value) {
948                    self.satisfy(line, "a folded thread unfolds when it grows, as in mutt");
949                } else {
950                    self.no_uncollapse_new = true;
951                }
952            }
953            "hide_limited" | "hide_top_limited" => {
954                if is_yes(value) {
955                    self.satisfy(line, "rmut never marks limited-out messages in the tree");
956                } else {
957                    self.differently(line, "rmut's tree shows only what the limit shows");
958                }
959            }
960            "thread_received" => {
961                self.differently(
962                    line,
963                    "rmut threads by References only; there is no subject threading to date",
964                );
965            }
966            "mail_check_recent" => {
967                if is_yes(value) {
968                    self.satisfy(line, "only a mailbox that grew is announced, as in mutt");
969                } else {
970                    self.no_mail_check_recent = true;
971                }
972            }
973            "check_new" => {
974                if is_yes(value) {
975                    self.satisfy(line, "the open maildir is rescanned as it changes, as in mutt");
976                } else {
977                    self.no_check_new = true;
978                }
979            }
980            "menu_move_off" => {
981                if is_yes(value) {
982                    self.satisfy(line, "the last message may scroll off the bottom, as in mutt");
983                } else {
984                    self.no_menu_move_off = true;
985                }
986            }
987            "menu_context" => match v.parse::<usize>() {
988                Ok(0) => self.satisfy(line, "no context lines is the default"),
989                Ok(n) => self.menu_context = Some(n),
990                Err(_) => self.skip(line, "menu_context wants a number"),
991            },
992            "help" => {
993                if is_yes(value) {
994                    self.satisfy(line, "the help bar is on by default");
995                } else {
996                    self.no_help = true;
997                }
998            }
999            "sort_browser" => match v.as_str() {
1000                "alpha" => self.satisfy(line, "the browser sorts by name by default"),
1001                _ => self.sort_browser = Some(v.clone()),
1002            },
1003            "sort_alias" => match v.as_str() {
1004                "address" => self.satisfy(line, "completion sorts by address by default"),
1005                _ => self.sort_alias = Some(v.clone()),
1006            },
1007            "shell" => self.shell = Some(v.clone()),
1008            "ispell" => match v.as_str() {
1009                "ispell" => self.satisfy(line, "ispell is the default"),
1010                _ => self.ispell = Some(v.clone()),
1011            },
1012            "tmpdir" => self.tmpdir = Some(v.clone()),
1013            "sleep_time" => {
1014                self.differently(line, "rmut never pauses on a message; it stays until the next key");
1015            }
1016            "read_inc" | "write_inc" | "net_inc" | "time_inc" => {
1017                self.differently(line, "progress shows as the connection reports it");
1018            }
1019            "print" => {
1020                // mutt's quadoption. rmut asks with Enter declining,
1021                // which is mutt's ask-no default.
1022                let want = v.trim().to_lowercase();
1023                match want.as_str() {
1024                    "ask-no" => self.satisfy(line, "p asks first, with Enter declining"),
1025                    "yes" | "no" | "ask-yes" => self.print_confirm = Some(want),
1026                    _ => self.skip(line, "print wants yes / no / ask-yes / ask-no"),
1027                }
1028            }
1029            "reverse_realname" => {
1030                if is_yes(value) {
1031                    self.satisfy(line, "the name comes over with the address");
1032                } else {
1033                    self.no_reverse_realname = true;
1034                }
1035            }
1036            "timeout" => self.differently(
1037                line,
1038                "rmut polls on a timer of its own, not on an idle keypress; [mail] poll_seconds is the interval",
1039            ),
1040            "strict_threads" => {
1041                if is_yes(value) {
1042                    self.satisfy(line, "rmut threads by References only, never by subject");
1043                } else {
1044                    self.skip(line, "rmut cannot be told to thread by subject");
1045                }
1046            }
1047            "duplicate_threads" => {
1048                if is_yes(value) {
1049                    self.differently(
1050                        line,
1051                        "rmut gives each copy of a Message-ID its own line; ~= finds them",
1052                    );
1053                } else {
1054                    self.satisfy(line, "rmut keeps duplicate Message-IDs apart");
1055                }
1056            }
1057            "hide_missing" | "hide_top_missing" => {
1058                if is_yes(value) {
1059                    self.satisfy(line, "rmut never draws a message it does not have");
1060                } else {
1061                    self.skip(line, "rmut cannot draw messages it does not have");
1062                }
1063            }
1064            "narrow_tree" => {
1065                if is_yes(value) {
1066                    self.satisfy(line, "rmut's tree is two columns a level already");
1067                } else {
1068                    self.differently(line, "rmut's tree is two columns a level, wide or not");
1069                }
1070            }
1071            "quit" => {
1072                let want = v.trim().to_lowercase();
1073                match want.as_str() {
1074                    "yes" => self.satisfy(line, "q leaves at once, as in mutt"),
1075                    "no" | "ask-yes" | "ask-no" => self.quit = Some(want),
1076                    _ => self.skip(line, "quit wants yes / no / ask-yes / ask-no"),
1077                }
1078            }
1079            "postpone" => {
1080                let want = v.trim().to_lowercase();
1081                match want.as_str() {
1082                    "ask-yes" => self.satisfy(line, "leaving a draft asks, as in mutt"),
1083                    "yes" | "no" | "ask-no" => self.postpone = Some(want),
1084                    _ => self.skip(line, "postpone wants yes / no / ask-yes / ask-no"),
1085                }
1086            }
1087            "recall" => {
1088                let want = v.trim().to_lowercase();
1089                match want.as_str() {
1090                    "ask-yes" | "ask-no" => {
1091                        self.satisfy(line, "rmut offers new-or-recall when drafts wait")
1092                    }
1093                    "yes" | "no" => self.recall = Some(want),
1094                    _ => self.skip(line, "recall wants yes / no / ask-yes / ask-no"),
1095                }
1096            }
1097            "confirmappend" => {
1098                if is_yes(value) {
1099                    self.confirmappend = true;
1100                } else {
1101                    self.satisfy(line, "rmut adds to an existing mailbox without asking");
1102                }
1103            }
1104            "save_name" => {
1105                if is_yes(value) {
1106                    self.save_name = true;
1107                } else {
1108                    self.satisfy(line, "the save prompt offers [mail] save, as rmut always has");
1109                }
1110            }
1111            "force_name" => {
1112                if is_yes(value) {
1113                    self.force_name = true;
1114                } else {
1115                    self.satisfy(line, "rmut offers a named mailbox only when it exists");
1116                }
1117            }
1118            "move" => {
1119                if is_yes(value) {
1120                    self.differently(
1121                        line,
1122                        "rmut leaves read mail where it is; a folder-hook with a macro can move it",
1123                    );
1124                } else {
1125                    self.satisfy(line, "rmut leaves read mail where it is");
1126                }
1127            }
1128            "pager_stop" => {
1129                if is_yes(value) {
1130                    self.pager_stop = true;
1131                } else {
1132                    self.satisfy(line, "paging past the end opens the next message, as in mutt");
1133                }
1134            }
1135            "markers" => {
1136                if is_yes(value) {
1137                    self.satisfy(line, "rmut marks wrapped lines with + already");
1138                } else {
1139                    self.markers_off = true;
1140                }
1141            }
1142            "smart_wrap" => {
1143                if is_yes(value) {
1144                    self.satisfy(line, "rmut wraps at word boundaries already");
1145                } else {
1146                    self.smart_wrap_off = true;
1147                }
1148            }
1149            "collapse_unread" => {
1150                if is_yes(value) {
1151                    self.satisfy(line, "rmut folds every thread, as mutt does by default");
1152                } else {
1153                    self.collapse_unread_off = true;
1154                }
1155            }
1156            "uncollapse_jump" => {
1157                if is_yes(value) {
1158                    self.uncollapse_jump = true;
1159                } else {
1160                    self.satisfy(line, "unfolding keeps the cursor where it was, as in mutt");
1161                }
1162            }
1163            "hide_thread_subject" => {
1164                if is_yes(value) {
1165                    self.hide_thread_subject = true;
1166                } else {
1167                    self.satisfy(line, "rmut shows every subject in a thread by default");
1168                }
1169            }
1170            "sidebar_visible" => {
1171                if is_yes(value) {
1172                    self.sidebar_visible = true;
1173                } else {
1174                    self.satisfy(line, "the sidebar is hidden until B shows it");
1175                }
1176            }
1177            "sidebar_width" => match v.trim().parse::<u16>() {
1178                Ok(n) => self.sidebar_width = Some(n),
1179                Err(_) => self.skip(line, "not a number"),
1180            },
1181            "sidebar_format" => self.differently(
1182                line,
1183                "rmut's sidebar draws the mailbox and its new count, with no format string",
1184            ),
1185            "sidebar_short_path" | "sidebar_delim_chars" | "sidebar_folder_indent" => {
1186                self.differently(line, "rmut's sidebar shows the mailbox as configured")
1187            }
1188            "alias_file" => self.alias_file = Some(v),
1189            "imap_idle" => {
1190                if is_yes(value) {
1191                    self.satisfy(line, "rmut IDLEs whenever the server offers it");
1192                } else {
1193                    self.skip(line, "rmut cannot be told not to IDLE");
1194                }
1195            }
1196            "imap_keepalive" => self.differently(
1197                line,
1198                "rmut re-issues IDLE about every 25 minutes; [mail] poll_seconds is the fallback poll",
1199            ),
1200            "header_cache" | "message_cachedir" | "header_cache_backend" => self.differently(
1201                line,
1202                "rmut keeps its own header and body cache under ~/.cache/rmut",
1203            ),
1204            "crypt_use_gpgme" => {
1205                self.differently(line, "rmut runs gpg(1) directly, not through GPGME")
1206            }
1207            "mailcap_path" => self.differently(
1208                line,
1209                "rmut reads the files $MAILCAPS names, or the usual mailcap places",
1210            ),
1211            "implicit_autoview" => self.differently(
1212                line,
1213                "an empty [filters] command takes the mailcap one, per type",
1214            ),
1215            "attribution" => self.attribution = Some(v),
1216            "indent_string" => self.indent_string = Some(v),
1217            "reply_regexp" => self.reply_regexp = Some(v),
1218            "simple_search" => self.simple_search = Some(v),
1219            "wrap_search" => {
1220                if is_yes(value) {
1221                    self.satisfy(line, "search wraps around by default, as in mutt");
1222                } else {
1223                    self.no_wrap_search = true;
1224                }
1225            }
1226            "sort_re" => {
1227                if is_yes(value) {
1228                    self.satisfy(line, "rmut never threads by subject, so this is moot");
1229                } else {
1230                    self.skip(line, "rmut cannot be told to thread by subject");
1231                }
1232            }
1233            "forward_format" => self.forward_format = Some(v),
1234            "include" => {
1235                // mutt's quadoption: yes / no / ask-yes / ask-no.
1236                let want = v.trim().to_lowercase();
1237                match want.as_str() {
1238                    "yes" | "no" | "ask-yes" | "ask-no" => self.include = Some(want),
1239                    _ => self.skip(line, "include wants yes / no / ask-yes / ask-no"),
1240                }
1241            }
1242            "forward_quote" => {
1243                if is_yes(value) {
1244                    self.forward_quote = true;
1245                } else {
1246                    self.satisfy(line, "a forward comes in unquoted, as in mutt");
1247                }
1248            }
1249            "signature" => self.signature = Some(v),
1250            "sig_on_top" => {
1251                if is_yes(value) {
1252                    self.sig_on_top = true;
1253                } else {
1254                    self.satisfy(line, "the signature goes below the quote, as in mutt");
1255                }
1256            }
1257            "hostname" => self.hostname = Some(v),
1258            "use_domain" => self.differently(
1259                line,
1260                "rmut takes the Message-ID host from the system name or [mail] hostname",
1261            ),
1262            "user_agent" => {
1263                if is_yes(value) {
1264                    self.user_agent = true;
1265                } else {
1266                    self.satisfy(line, "rmut adds no User-Agent by default");
1267                }
1268            }
1269            "sig_dashes" => {
1270                if !is_yes(value) {
1271                    self.no_sig_dashes = true;
1272                } else {
1273                    self.satisfy(line, "the \"-- \" line is rmut's default too");
1274                }
1275            }
1276            "abort_nosubject" => {
1277                let want = v.trim().to_lowercase();
1278                match want.as_str() {
1279                    "ask-yes" => self.satisfy(line, "rmut asks, with Enter aborting"),
1280                    "yes" | "no" | "ask-no" => self.abort_nosubject = Some(want),
1281                    _ => self.skip(line, "abort_nosubject wants yes / no / ask-yes / ask-no"),
1282                }
1283            }
1284            "abort_unmodified" => {
1285                if is_yes(value) {
1286                    self.satisfy(line, "an untouched first edit drops the draft already");
1287                } else {
1288                    self.no_abort_unmodified = true;
1289                }
1290            }
1291            "reply_to" => {
1292                // rmut asks whenever a Reply-To differs from From,
1293                // which is mutt's ask-yes; the other three would be
1294                // rmut answering it for you.
1295                match v.trim().to_lowercase().as_str() {
1296                    "ask-yes" => self.satisfy(line, "rmut asks before taking a Reply-To"),
1297                    _ => self.skip(line, "rmut always asks about a Reply-To (mutt's ask-yes)"),
1298                }
1299            }
1300            "honor_followup_to" => {
1301                if is_yes(value) {
1302                    self.satisfy(line, "a group reply honors Mail-Followup-To already");
1303                } else {
1304                    self.skip(line, "rmut always honors a sender's Mail-Followup-To");
1305                }
1306            }
1307            "askcc" => self.ask_cc = is_yes(value),
1308            "askbcc" => self.ask_bcc = is_yes(value),
1309            "fast_reply" => {
1310                if is_yes(&v) {
1311                    self.fast_reply = true;
1312                } else {
1313                    self.satisfy(line, "prompting is rmut's default");
1314                }
1315            }
1316            "autoedit" => {
1317                if is_yes(&v) {
1318                    self.autoedit = true;
1319                } else {
1320                    self.satisfy(line, "prompting is rmut's default");
1321                }
1322            }
1323            "copy" => {
1324                if is_yes(&v) {
1325                    self.satisfy(line, "the sent copy is rmut's default");
1326                } else {
1327                    self.no_copy = true;
1328                }
1329            }
1330            "forward_decode" => {
1331                if is_yes(&v) {
1332                    self.satisfy(line, "inline forwards always quote the decoded text");
1333                } else {
1334                    self.skip(line, "rmut always decodes when quoting a forward");
1335                }
1336            }
1337            "mime_forward_rest" => {
1338                self.satisfy(line, "the entire original message is attached");
1339            }
1340            "imap_peek" => {
1341                if is_yes(&v) {
1342                    self.satisfy(line, "fetches use BODY.PEEK; \\Seen is set only on sync");
1343                } else {
1344                    self.skip(line, "rmut never marks messages read while fetching");
1345                }
1346            }
1347            "menu_scroll" => {
1348                if is_yes(value) {
1349                    self.satisfy(line, "the index scrolls a line at a time by default");
1350                } else {
1351                    self.no_menu_scroll = true;
1352                }
1353            }
1354            "imap_authenticators" | "smtp_authenticators" => {
1355                let m = v.to_lowercase();
1356                if m.contains("oauthbearer") {
1357                    self.oauth = Some("oauthbearer".into());
1358                } else if m.contains("xoauth2") {
1359                    self.oauth = Some("xoauth2".into());
1360                } else if m.contains("plain") || m.contains("login") {
1361                    self.satisfy(line, "rmut negotiates AUTH PLAIN/LOGIN by itself");
1362                } else {
1363                    self.skip(
1364                        line,
1365                        "rmut supports PLAIN/LOGIN and OAuth (xoauth2/oauthbearer)",
1366                    );
1367                }
1368            }
1369            _ => self.skip(line, "no rmut equivalent"),
1370        }
1371    }
1372
1373    /// A folder-hook / send-hook whose command only sets from/realname
1374    /// becomes an [[identities]] rule, where it layers with the rest
1375    /// of rmut's identity handling. Any other folder-hook command
1376    /// becomes a [[folder_hooks]] entry, run as an enter-command line
1377    /// when the mailbox opens; a send-hook doing something else has
1378    /// no rmut equivalent and is skipped.
1379    fn hook(&mut self, folder_hook: bool, args: &[String], line: &str) {
1380        let [pattern, command] = args else {
1381            self.skip(line, "unrecognized hook syntax");
1382            return;
1383        };
1384        let Some(glob) = hook_glob(pattern) else {
1385            self.skip(line, "the pattern does not translate to a glob");
1386            return;
1387        };
1388        let tokens = tokenize(command);
1389        let only_identity_sets = "only 'set from/realname' send-hooks translate";
1390        let identity_sets = || -> Option<(Option<String>, Option<String>)> {
1391            if tokens.first().map(String::as_str) != Some("set") {
1392                return None;
1393            }
1394            let (mut name, mut email) = (None, None);
1395            for (key, value) in assignments(&tokens[1..]) {
1396                match key.as_str() {
1397                    "realname" => name = Some(value),
1398                    "from" => {
1399                        let (n, e) = split_from(&value);
1400                        if name.is_none() {
1401                            name = n;
1402                        }
1403                        email = Some(e);
1404                    }
1405                    _ => return None,
1406                }
1407            }
1408            (name.is_some() || email.is_some()).then_some((name, email))
1409        };
1410        if let Some((name, email)) = identity_sets() {
1411            self.identity_rules.push(IdRule {
1412                folder: folder_hook.then(|| glob.clone()),
1413                recipient: (!folder_hook).then_some(glob),
1414                name,
1415                email,
1416            });
1417            return;
1418        }
1419        if !folder_hook {
1420            self.skip(line, only_identity_sets);
1421            return;
1422        }
1423        match crate::command::parse(command) {
1424            Ok(cmds) if !cmds.is_empty() => self.folder_hook_lines.push((glob, command.clone())),
1425            Ok(_) => self.skip(line, "the hook command does nothing"),
1426            Err(err) => self.skip(line, &err),
1427        }
1428    }
1429
1430    /// message-hook / reply-hook: the pattern stays a rmut pattern and
1431    /// the command stays a command line, so both have to parse.
1432    fn command_hook(&mut self, cmd: &str, args: &[String], line: &str) {
1433        let [pattern, command] = args else {
1434            self.skip(line, &format!("{cmd} PATTERN COMMAND"));
1435            return;
1436        };
1437        if let Err(err) = crate::pattern::parse(pattern) {
1438            self.skip(line, &format!("pattern does not translate: {err}"));
1439            return;
1440        }
1441        match crate::command::parse(command) {
1442            Ok(cmds) if !cmds.is_empty() => {
1443                let table = if cmd == "message-hook" {
1444                    &mut self.message_hooks
1445                } else {
1446                    &mut self.reply_hooks
1447                };
1448                table.push((pattern.clone(), command.clone()));
1449            }
1450            Ok(_) => self.skip(line, "the hook command does nothing"),
1451            Err(err) => self.skip(line, &err),
1452        }
1453    }
1454
1455    /// fcc-hook / fcc-save-hook. A bare pattern gets mutt's
1456    /// $default_hook expansion, so "boss@example.com" means what mutt
1457    /// means by it; fcc-save-hook also fills [mail] save when it is
1458    /// the catch-all, which is what save-hook already does.
1459    fn fcc_hook(&mut self, cmd: &str, args: &[String], line: &str) {
1460        let [pattern, mailbox] = args else {
1461            self.skip(line, &format!("{cmd} PATTERN MAILBOX"));
1462            return;
1463        };
1464        let expanded = default_hook_pattern(pattern);
1465        if let Err(err) = crate::pattern::parse(&expanded) {
1466            self.skip(line, &format!("pattern does not translate: {err}"));
1467            return;
1468        }
1469        self.fcc_hooks.push((expanded, mailbox.clone()));
1470        if cmd == "fcc-save-hook" && matches!(pattern.as_str(), "." | ".*" | "~A") {
1471            self.save_default = Some(mailbox.clone());
1472        }
1473    }
1474
1475    /// mutt's +x / =x mean "under $folder"; for an IMAP folder that is
1476    /// an account folder spec, locally a joined path. A full
1477    /// imap[s]:// URL maps to the mailbox in its path.
1478    fn expand_mailbox(&self, value: &str) -> String {
1479        if is_imap_url(value) {
1480            return format!("imap:{ACCOUNT}/{}", url_mailbox(value));
1481        }
1482        let Some(rest) = value.strip_prefix(['+', '=']) else {
1483            return value.to_string();
1484        };
1485        let rest = rest.trim_matches('/');
1486        match &self.folder {
1487            Some(f) if is_imap_url(f) => format!("imap:{ACCOUNT}/{rest}"),
1488            Some(f) => format!("{}/{rest}", f.trim_end_matches('/')),
1489            None => rest.to_string(),
1490        }
1491    }
1492
1493    fn bind(&mut self, args: &[String], line: &str) {
1494        let [menus, key, function] = args else {
1495            self.skip(line, "unrecognized bind syntax");
1496            return;
1497        };
1498        if function == "noop" {
1499            self.satisfy(line, "unbound keys already do nothing");
1500            return;
1501        }
1502        let Some(key) = convert_key(key) else {
1503            self.skip(line, "key has no rmut syntax");
1504            return;
1505        };
1506        let mut used = false;
1507        for menu in menus.split(',') {
1508            let table = match menu {
1509                "index" => &mut self.keys_index,
1510                "pager" => &mut self.keys_pager,
1511                _ => continue,
1512            };
1513            let actions = match menu {
1514                "index" => index_function(function),
1515                _ => pager_function(function),
1516            };
1517            match actions {
1518                Some(action) => {
1519                    table.insert(action, key.clone());
1520                    used = true;
1521                }
1522                None => self.skip(line, &format!("no rmut action for {function} in {menu}")),
1523            }
1524        }
1525        if !used && !menus.split(',').any(|m| m == "index" || m == "pager") {
1526            self.skip(line, "only index and pager menus exist in rmut");
1527        }
1528    }
1529
1530    /// `macro MENU KEY SEQUENCE [description]`, translated when the
1531    /// sequence is plain keys and prompt input; mutt function names
1532    /// (`<collapse-all>` etc.) have no rmut equivalent.
1533    fn mutt_macro(&mut self, args: &[String], line: &str) {
1534        let (menus, key, seq) = match args {
1535            [m, k, s] | [m, k, s, _] => (m, k, s),
1536            _ => {
1537                self.skip(line, "unrecognized macro syntax");
1538                return;
1539            }
1540        };
1541        let Some(key) = convert_key(key) else {
1542            self.skip(line, "key has no rmut syntax");
1543            return;
1544        };
1545        let Some(sequence) = convert_sequence(seq) else {
1546            self.skip(
1547                line,
1548                "only plain keys translate; mutt function names do not",
1549            );
1550            return;
1551        };
1552        let mut used = false;
1553        for menu in menus.split(',') {
1554            let table = match menu {
1555                "index" => &mut self.macros_index,
1556                "pager" => &mut self.macros_pager,
1557                _ => continue,
1558            };
1559            table.insert(key.clone(), sequence.clone());
1560            used = true;
1561        }
1562        if !used {
1563            self.skip(line, "only index and pager menus exist in rmut");
1564        }
1565    }
1566
1567    /// `auto_view` / `unauto_view`: a [filters] entry per type. The
1568    /// command is left empty, which is rmut's "look it up in mailcap",
1569    /// exactly where mutt looks for it.
1570    fn auto_view(&mut self, add: bool, mime: &str, line: &str) {
1571        let mime = mime.to_lowercase();
1572        if !add {
1573            if mime == "*" {
1574                self.filters.clear();
1575            } else {
1576                self.filters.remove(&mime);
1577            }
1578            return;
1579        }
1580        match mime.as_str() {
1581            "application/pgp" | "application/pgp-signature" | "application/pgp-encrypted" => {
1582                self.satisfy(line, "PGP is handled natively");
1583            }
1584            _ => {
1585                self.filters.entry(mime).or_default();
1586            }
1587        }
1588    }
1589
1590    fn color(&mut self, args: &[String], line: &str) {
1591        let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2)) else {
1592            self.skip(line, "unrecognized color syntax");
1593            return;
1594        };
1595        let fg = convert_color(fg);
1596        let bg = convert_color(bg);
1597        // rmut's index slots take one color; when the foreground says
1598        // nothing (black/white/default on a colored background), the
1599        // background is what the user actually sees.
1600        let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
1601            bg.clone()
1602        } else {
1603            fg.clone()
1604        };
1605        // `color quoted` / `color quotedN`: the depth palette.
1606        if let Some(n) = object.strip_prefix("quoted")
1607            && let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
1608        {
1609            self.quoted_colors.insert(depth, vivid);
1610            return;
1611        }
1612        match (object.as_str(), args.get(3).map(String::as_str)) {
1613            ("status", _) => {
1614                self.colors.insert("status_fg", fg);
1615                self.colors.insert("status_bg", bg);
1616            }
1617            ("search", _) => {
1618                if fg != "default" {
1619                    self.colors.insert("search_fg", fg.clone());
1620                }
1621                if bg != "default" {
1622                    self.colors.insert("search_bg", bg.clone());
1623                }
1624            }
1625            ("body", Some(regex)) => {
1626                self.color_body_rules.push((regex.to_string(), fg, bg));
1627            }
1628            ("header" | "hdrdefault", _) => {
1629                self.colors.insert("header", fg);
1630            }
1631            ("error", _) => {
1632                self.colors.insert("error", vivid);
1633            }
1634            // rmut has a slot of its own for these three, but a slot
1635            // carries one colour: a line that paints a background as
1636            // well keeps both, as a rule, which is what it looked
1637            // like in mutt.
1638            ("index", Some("~D")) if bg == "default" => {
1639                self.colors.insert("deleted", vivid);
1640            }
1641            ("index", Some("~F")) if bg == "default" => {
1642                self.colors.insert("flagged", vivid);
1643            }
1644            ("index", Some("~T")) if bg == "default" => {
1645                self.colors.insert("tagged", vivid);
1646            }
1647            // Any other pattern rmut's engine parses becomes a
1648            // [[color_index]] rule.
1649            ("index", Some(pattern)) if crate::pattern::parse(pattern).is_ok() => {
1650                self.color_index_rules.push((pattern.to_string(), fg, bg));
1651            }
1652            _ => self.skip(line, "no rmut color slot"),
1653        }
1654    }
1655
1656    // ---- output ----
1657
1658    fn to_toml(&self) -> String {
1659        let mut out = String::from("# generated by rmut --import-muttrc; review before use\n");
1660        if self.name.is_some()
1661            || self.email.is_some()
1662            || self.reverse_name
1663            || self.no_reverse_realname
1664        {
1665            out += "\n[identity]\n";
1666            if let Some(n) = &self.name {
1667                out += &format!("name = {}\n", quote(n));
1668            }
1669            if let Some(e) = &self.email {
1670                out += &format!("email = {}\n", quote(e));
1671            }
1672            if self.reverse_name {
1673                out += "reverse_name = true\n";
1674            }
1675            if self.no_reverse_realname {
1676                out += "reverse_realname = false\n";
1677            }
1678        }
1679        for rule in &self.identity_rules {
1680            out += "\n[[identities]]\n";
1681            if let Some(f) = &rule.folder {
1682                out += &format!("folder = {}\n", quote(f));
1683            }
1684            if let Some(r) = &rule.recipient {
1685                out += &format!("recipient = {}\n", quote(r));
1686            }
1687            if let Some(n) = &rule.name {
1688                out += &format!("name = {}\n", quote(n));
1689            }
1690            if let Some(e) = &rule.email {
1691                out += &format!("email = {}\n", quote(e));
1692            }
1693        }
1694        for (glob, command) in &self.folder_hook_lines {
1695            out += &format!(
1696                "\n[[folder_hooks]]\nfolder = {}\ncommand = {}\n",
1697                quote(glob),
1698                quote(command)
1699            );
1700        }
1701        for (table, hooks) in [
1702            ("message_hooks", &self.message_hooks),
1703            ("reply_hooks", &self.reply_hooks),
1704        ] {
1705            for (pattern, command) in hooks {
1706                out += &format!(
1707                    "\n[[{table}]]\npattern = {}\ncommand = {}\n",
1708                    quote(pattern),
1709                    quote(command)
1710                );
1711            }
1712        }
1713        for (pattern, mailbox) in &self.fcc_hooks {
1714            out += &format!(
1715                "\n[[fcc_hooks]]\npattern = {}\nmailbox = {}\n",
1716                quote(pattern),
1717                quote(&self.expand_mailbox(mailbox))
1718            );
1719        }
1720        for (address, key) in &self.crypt_hooks {
1721            out += &format!(
1722                "\n[[crypt_hooks]]\naddress = {}\nkey = {}\n",
1723                quote(address),
1724                quote(key)
1725            );
1726        }
1727        // A full URL in spoolfile also identifies the IMAP server when
1728        // $folder is local or unset.
1729        let imap = self
1730            .folder
1731            .as_deref()
1732            .filter(|f| is_imap_url(f))
1733            .or_else(|| self.spoolfile.as_deref().filter(|s| is_imap_url(s)));
1734        let mut mailboxes = Vec::new();
1735        if let Some(spool) = &self.spoolfile {
1736            mailboxes.push(match imap {
1737                Some(_) if is_imap_url(spool) => {
1738                    format!("imap:{ACCOUNT}/{}", url_mailbox(spool))
1739                }
1740                Some(_) => {
1741                    let inbox = spool.trim_start_matches(['+', '=']).trim_matches('/');
1742                    format!("imap:{ACCOUNT}/{inbox}")
1743                }
1744                None => self.expand_mailbox(spool),
1745            });
1746        }
1747        for m in &self.mailboxes {
1748            if !mailboxes.contains(m) {
1749                mailboxes.push(m.clone());
1750            }
1751        }
1752        let sent_local = self.sent.as_deref().filter(|_| imap.is_none());
1753        // $folder itself is worth carrying over: rmut expands +x / =x
1754        // at runtime too, which is what makes an imported macro like
1755        // "<save-message>=archive<enter>" land in the right mailbox.
1756        let folder_setting = self.folder.clone().filter(|f| !is_imap_url(f));
1757        if folder_setting.is_some()
1758            || !mailboxes.is_empty()
1759            || sent_local.is_some()
1760            || self.postponed.is_some()
1761            || self.sendmail.is_some()
1762            || self.editor.is_some()
1763            || self.poll_seconds.is_some()
1764            || self.print.is_some()
1765            || self.query_command.is_some()
1766            || self.trash.is_some()
1767            || self.save_default.is_some()
1768            || self.forward_attach
1769            || self.forward_ask
1770            || self.fast_reply
1771            || self.quit.is_some()
1772            || self.postpone.is_some()
1773            || self.recall.is_some()
1774            || self.confirmappend
1775            || self.save_name
1776            || self.force_name
1777            || self.alias_file.is_some()
1778            || self.attribution.is_some()
1779            || self.indent_string.is_some()
1780            || self.reply_regexp.is_some()
1781            || self.simple_search.is_some()
1782            || self.no_wrap_search
1783            || self.forward_format.is_some()
1784            || self.include.is_some()
1785            || self.ask_cc
1786            || self.ask_bcc
1787            || self.autoedit
1788            || self.no_copy
1789            || self.new_mail_command.is_some()
1790            || self.edit_headers_on
1791            || !self.lists.is_empty()
1792            || !self.subscribed.is_empty()
1793            || !self.alternates.is_empty()
1794            || !self.my_hdr.is_empty()
1795            || self.metoo
1796            || self.no_mark_old
1797            || self.no_delete_untag
1798            || self.flag_safe
1799            || self.maildir_trash
1800            || self.no_mail_check_recent
1801            || self.no_check_new
1802            || self.sort_alias.is_some()
1803            || self.shell.is_some()
1804            || self.tmpdir.is_some()
1805            || self.ispell.is_some()
1806            || self.print_confirm.is_some()
1807            || self.forward_quote
1808            || self.signature.is_some()
1809            || self.no_sig_dashes
1810            || self.sig_on_top
1811            || self.hostname.is_some()
1812            || self.user_agent
1813            || self.abort_nosubject.is_some()
1814            || self.no_abort_unmodified
1815            || self.text_flowed
1816            || self.delete.is_some()
1817            || self.abort_noattach.is_some()
1818            || self.attach_keyword.is_some()
1819        {
1820            out += "\n[mail]\n";
1821            if let Some(f) = &folder_setting {
1822                out += &format!("folder = {}\n", quote(f));
1823            }
1824            for (key, values) in [
1825                ("lists", &self.lists),
1826                ("subscribed", &self.subscribed),
1827                ("alternates", &self.alternates),
1828                ("my_hdr", &self.my_hdr),
1829            ] {
1830                if !values.is_empty() {
1831                    let list: Vec<String> = values.iter().map(|v| quote(v)).collect();
1832                    out += &format!("{key} = [{}]\n", list.join(", "));
1833                }
1834            }
1835            if !mailboxes.is_empty() {
1836                let list: Vec<String> = mailboxes.iter().map(|m| quote(m)).collect();
1837                out += &format!("mailboxes = [{}]\n", list.join(", "));
1838            }
1839            if let Some(s) = sent_local {
1840                out += &format!("sent = {}\n", quote(&self.expand_mailbox(s)));
1841            }
1842            if let Some(p) = &self.postponed {
1843                out += &format!("postponed = {}\n", quote(&self.expand_mailbox(p)));
1844            }
1845            if let Some(s) = &self.sendmail {
1846                out += &format!("sendmail = {}\n", quote(s));
1847            }
1848            if let Some(e) = &self.editor {
1849                out += &format!("editor = {}\n", quote(e));
1850            }
1851            if let Some(n) = self.poll_seconds {
1852                out += &format!("poll_seconds = {n}\n");
1853            }
1854            if let Some(p) = &self.print {
1855                out += &format!("print = {}\n", quote(p));
1856            }
1857            if let Some(q) = &self.query_command {
1858                out += &format!("query_command = {}\n", quote(q));
1859            }
1860            if let Some(t) = &self.trash {
1861                out += &format!("trash = {}\n", quote(&self.expand_mailbox(t)));
1862            }
1863            if let Some(save) = &self.save_default {
1864                out += &format!("save = {}\n", quote(&self.expand_mailbox(save)));
1865            }
1866            if self.forward_ask {
1867                out += "forward = \"ask\"\n";
1868            } else if self.forward_attach {
1869                out += "forward = \"attach\"\n";
1870            }
1871            if self.fast_reply {
1872                out += "fast_reply = true\n";
1873            }
1874            if let Some(v) = &self.postpone {
1875                out += &format!("postpone = {}\n", quote(v));
1876            }
1877            if let Some(v) = &self.recall {
1878                out += &format!("recall = {}\n", quote(v));
1879            }
1880            if let Some(v) = &self.quit {
1881                out += &format!("quit = {}\n", quote(v));
1882            }
1883            if self.confirmappend {
1884                out += "confirmappend = true\n";
1885            }
1886            if self.save_name {
1887                out += "save_name = true\n";
1888            }
1889            if self.force_name {
1890                out += "force_name = true\n";
1891            }
1892            if let Some(v) = &self.alias_file {
1893                out += &format!("alias_file = {}\n", quote(v));
1894            }
1895            if let Some(v) = &self.attribution {
1896                out += &format!("attribution = {}\n", quote(v));
1897            }
1898            if let Some(v) = &self.indent_string {
1899                out += &format!("indent_string = {}\n", quote(v));
1900            }
1901            if let Some(v) = &self.reply_regexp {
1902                out += &format!("reply_regexp = {}\n", quote(v));
1903            }
1904            if let Some(v) = &self.simple_search {
1905                out += &format!("simple_search = {}\n", quote(v));
1906            }
1907            if self.no_wrap_search {
1908                out += "wrap_search = false\n";
1909            }
1910            if let Some(v) = &self.forward_format {
1911                out += &format!("forward_format = {}\n", quote(v));
1912            }
1913            if let Some(v) = &self.include {
1914                out += &format!("include = {}\n", quote(v));
1915            }
1916            if self.ask_cc {
1917                out += "ask_cc = true\n";
1918            }
1919            if self.ask_bcc {
1920                out += "ask_bcc = true\n";
1921            }
1922            if self.autoedit {
1923                out += "autoedit = true\n";
1924            }
1925            if self.no_copy {
1926                out += "copy = false\n";
1927            }
1928            if self.metoo {
1929                out += "metoo = true\n";
1930            }
1931            if self.no_mark_old {
1932                out += "mark_old = false\n";
1933            }
1934            if self.no_delete_untag {
1935                out += "delete_untag = false\n";
1936            }
1937            if self.flag_safe {
1938                out += "flag_safe = true\n";
1939            }
1940            if self.maildir_trash {
1941                out += "maildir_trash = true\n";
1942            }
1943            if self.no_mail_check_recent {
1944                out += "mail_check_recent = false\n";
1945            }
1946            if self.no_check_new {
1947                out += "check_new = false\n";
1948            }
1949            if let Some(v) = &self.sort_alias {
1950                out += &format!("sort_alias = {}\n", quote(v));
1951            }
1952            if let Some(v) = &self.shell {
1953                out += &format!("shell = {}\n", quote(v));
1954            }
1955            if let Some(v) = &self.tmpdir {
1956                out += &format!("tmpdir = {}\n", quote(v));
1957            }
1958            if let Some(v) = &self.ispell {
1959                out += &format!("ispell = {}\n", quote(v));
1960            }
1961            if let Some(v) = &self.print_confirm {
1962                out += &format!("print_confirm = {}\n", quote(v));
1963            }
1964            if self.forward_quote {
1965                out += "forward_quote = true\n";
1966            }
1967            if let Some(v) = &self.signature {
1968                out += &format!("signature = {}\n", quote(v));
1969            }
1970            if self.no_sig_dashes {
1971                out += "sig_dashes = false\n";
1972            }
1973            if self.sig_on_top {
1974                out += "sig_on_top = true\n";
1975            }
1976            if let Some(v) = &self.hostname {
1977                out += &format!("hostname = {}\n", quote(v));
1978            }
1979            if self.user_agent {
1980                out += "user_agent = true\n";
1981            }
1982            if let Some(v) = &self.abort_nosubject {
1983                out += &format!("abort_nosubject = {}\n", quote(v));
1984            }
1985            if self.no_abort_unmodified {
1986                out += "abort_unmodified = false\n";
1987            }
1988            if self.text_flowed {
1989                out += "text_flowed = true\n";
1990            }
1991            if let Some(v) = &self.delete {
1992                out += &format!("delete = {}\n", quote(v));
1993            }
1994            if let Some(v) = &self.abort_noattach {
1995                out += &format!("abort_noattach = {}\n", quote(v));
1996            }
1997            if let Some(v) = &self.attach_keyword {
1998                out += &format!("attach_keyword = {}\n", quote(v));
1999            }
2000            if let Some(c) = &self.new_mail_command {
2001                out += &format!("new_mail_command = {}\n", quote(c));
2002            }
2003            if self.edit_headers_on {
2004                out += "edit_headers = true\n";
2005            }
2006        }
2007        if self.index_format.is_some()
2008            || self.sort.is_some()
2009            || self.sort_aux.is_some()
2010            || self.date_format.is_some()
2011            || self.collapse_unread_off
2012            || self.uncollapse_jump
2013            || self.hide_thread_subject
2014            || self.no_uncollapse_new
2015        {
2016            out += "\n[index]\n";
2017            if let Some(f) = &self.index_format {
2018                out += "# rmut renders %C %Z %d %F %L %c %l %s and %?X?then&else? conditionals\n";
2019                out += &format!("format = {}\n", quote(f));
2020            }
2021            if let Some(sort) = &self.sort {
2022                out += &format!("sort = {}\n", quote(sort));
2023            }
2024            if let Some(aux) = &self.sort_aux {
2025                out += &format!("sort_aux = {}\n", quote(aux));
2026            }
2027            if let Some(df) = &self.date_format {
2028                out += &format!("date_format = {}\n", quote(df));
2029            }
2030            if self.collapse_unread_off {
2031                out += "collapse_unread = false\n";
2032            }
2033            if self.uncollapse_jump {
2034                out += "uncollapse_jump = true\n";
2035            }
2036            if self.hide_thread_subject {
2037                out += "hide_thread_subject = true\n";
2038            }
2039            if self.no_uncollapse_new {
2040                out += "uncollapse_new = false\n";
2041            }
2042        }
2043        if self.pager_index_lines.is_some()
2044            || self.pager_context.is_some()
2045            || self.search_context.is_some()
2046            || self.quote_regexp.is_some()
2047            || self.pager_format.is_some()
2048            || self.wrap.is_some()
2049            || self.tilde
2050            || !self.hdr_ignore.is_empty()
2051            || !self.hdr_unignore.is_empty()
2052            || !self.hdr_order.is_empty()
2053            || self.no_reflow_text
2054            || self.pager_stop
2055            || self.markers_off
2056            || self.smart_wrap_off
2057            || !self.alternative_order.is_empty()
2058        {
2059            out += "\n[pager]\n";
2060            if let Some(n) = self.pager_index_lines {
2061                out += &format!("index_lines = {n}\n");
2062            }
2063            if let Some(n) = self.pager_context {
2064                out += &format!("context = {n}\n");
2065            }
2066            if let Some(n) = self.search_context {
2067                out += &format!("search_context = {n}\n");
2068            }
2069            if let Some(re) = &self.quote_regexp {
2070                out += &format!("quote_regexp = {}\n", quote(re));
2071            }
2072            for (key, list) in [
2073                ("ignore", &self.hdr_ignore),
2074                ("unignore", &self.hdr_unignore),
2075                ("hdr_order", &self.hdr_order),
2076            ] {
2077                if !list.is_empty() {
2078                    let items: Vec<String> = list.iter().map(|s| quote(s)).collect();
2079                    out += &format!("{key} = [{}]\n", items.join(", "));
2080                }
2081            }
2082            if let Some(f) = &self.pager_format {
2083                out += "# rmut renders %C %m %n %s %Z %P %f and %>X here\n";
2084                out += &format!("format = {}\n", quote(f));
2085            }
2086            if let Some(n) = self.wrap {
2087                out += &format!("wrap = {n}\n");
2088            }
2089            if self.tilde {
2090                out += "tilde = true\n";
2091            }
2092            if self.no_reflow_text {
2093                out += "reflow_text = false\n";
2094            }
2095            if self.pager_stop {
2096                out += "pager_stop = true\n";
2097            }
2098            if self.markers_off {
2099                out += "markers = false\n";
2100            }
2101            if self.smart_wrap_off {
2102                out += "smart_wrap = false\n";
2103            }
2104            if !self.alternative_order.is_empty() {
2105                let items: Vec<String> = self.alternative_order.iter().map(|s| quote(s)).collect();
2106                out += &format!("alternative_order = [{}]\n", items.join(", "));
2107            }
2108        }
2109        if !self.filters.is_empty() {
2110            out += "\n[filters]\n# auto_view: an empty command means rmut takes it from your\n# mailcap (the first copiousoutput entry), as mutt does; put a\n# command here to override it\n";
2111            for (mime, command) in &self.filters {
2112                out += &format!("{} = {}\n", quote(mime), quote(command));
2113            }
2114        }
2115        if !self.colors.is_empty() || !self.quoted_colors.is_empty() {
2116            out += "\n[colors]\n";
2117            for (k, v) in &self.colors {
2118                out += &format!("{k} = {}\n", quote(v));
2119            }
2120            for (n, v) in &self.quoted_colors {
2121                let key = if *n == 0 {
2122                    "quoted".to_string()
2123                } else {
2124                    format!("quoted{n}")
2125                };
2126                out += &format!("{key} = {}\n", quote(v));
2127            }
2128        }
2129        for (pattern, fg, bg) in &self.color_index_rules {
2130            out += "\n[[color_index]]\n";
2131            out += &format!("pattern = {}\n", quote(pattern));
2132            if fg != "default" {
2133                out += &format!("fg = {}\n", quote(fg));
2134            }
2135            if bg != "default" {
2136                out += &format!("bg = {}\n", quote(bg));
2137            }
2138        }
2139        for (pattern, fg, bg) in &self.color_body_rules {
2140            out += "\n[[color_body]]\n";
2141            out += &format!("pattern = {}\n", quote(pattern));
2142            if fg != "default" {
2143                out += &format!("fg = {}\n", quote(fg));
2144            }
2145            if bg != "default" {
2146                out += &format!("bg = {}\n", quote(bg));
2147            }
2148        }
2149        if self.sidebar_visible || self.sidebar_width.is_some() {
2150            out += "\n[sidebar]\n";
2151            if self.sidebar_visible {
2152                out += "visible = true\n";
2153            }
2154            if let Some(width) = self.sidebar_width {
2155                out += &format!("width = {width}\n");
2156            }
2157        }
2158        if self.connect_timeout.is_some() || self.certificate_file.is_some() || self.no_system_cas {
2159            out += "\n[net]\n";
2160            if let Some(secs) = self.connect_timeout {
2161                out += &format!("connect_timeout = {secs}\n");
2162            }
2163            if let Some(path) = &self.certificate_file {
2164                out += &format!("certificate_file = {}\n", quote(path));
2165            }
2166            if self.no_system_cas {
2167                out += "system_cas = false\n";
2168            }
2169        }
2170        if self.status_format.is_some()
2171            || self.title_format.is_some()
2172            || self.history_file.is_some()
2173            || self.status_on_top
2174            || self.arrow_cursor
2175            || self.no_menu_scroll
2176            || self.menu_context.is_some()
2177            || self.no_menu_move_off
2178            || self.no_help
2179            || self.sort_browser.is_some()
2180            || self.status_chars.is_some()
2181            || self.set_title
2182            || self.no_beep
2183            || self.beep_new
2184            || self.no_wait_key
2185        {
2186            out += "\n[ui]\n";
2187            if let Some(sf) = &self.status_format {
2188                out += "# rmut renders %f %m %M %n %u %d %F %t %s %V %r %v and\n";
2189                out += "# %?X?then&else? conditionals; other specifiers show literally\n";
2190                out += &format!("status_format = {}\n", quote(sf));
2191            }
2192            if self.set_title {
2193                out += "set_title = true\n";
2194            }
2195            if let Some(v) = &self.history_file {
2196                out += &format!("history_file = {}\n", quote(v));
2197            }
2198            if self.status_on_top {
2199                out += "status_on_top = true\n";
2200            }
2201            if self.arrow_cursor {
2202                out += "arrow_cursor = true\n";
2203            }
2204            if self.no_menu_scroll {
2205                out += "menu_scroll = false\n";
2206            }
2207            if let Some(n) = self.menu_context {
2208                out += &format!("menu_context = {n}\n");
2209            }
2210            if self.no_menu_move_off {
2211                out += "menu_move_off = false\n";
2212            }
2213            if self.no_help {
2214                out += "help = false\n";
2215            }
2216            if let Some(v) = &self.sort_browser {
2217                out += &format!("sort_browser = {}\n", quote(v));
2218            }
2219            if let Some(v) = &self.status_chars {
2220                out += &format!("status_chars = {}\n", quote(v));
2221            }
2222            if let Some(tf) = &self.title_format {
2223                out += &format!("title_format = {}\n", quote(tf));
2224            }
2225            if self.no_beep {
2226                out += "beep = false\n";
2227            }
2228            if self.beep_new {
2229                out += "beep_new = true\n";
2230            }
2231            if self.no_wait_key {
2232                out += "wait_key = false\n";
2233            }
2234        }
2235        for (section, table) in [("index", &self.keys_index), ("pager", &self.keys_pager)] {
2236            if !table.is_empty() {
2237                out += &format!("\n[keys.{section}]\n");
2238                for (action, key) in table {
2239                    out += &format!("{action} = {}\n", quote(key));
2240                }
2241            }
2242        }
2243        for (section, table) in [("index", &self.macros_index), ("pager", &self.macros_pager)] {
2244            if !table.is_empty() {
2245                out += &format!("\n[macros.{section}]\n");
2246                for (key, sequence) in table {
2247                    out += &format!("{} = {}\n", quote(key), quote(sequence));
2248                }
2249            }
2250        }
2251        if self.sign_key.is_some()
2252            || self.sign_by_default
2253            || self.encrypt_by_default
2254            || self.reply_sign
2255            || self.reply_encrypt
2256            || self.reply_sign_encrypted
2257        {
2258            out += "\n[pgp]\n";
2259            if let Some(k) = &self.sign_key {
2260                out += &format!("sign_key = {}\n", quote(k));
2261            }
2262            if self.sign_by_default {
2263                out += "sign_by_default = true\n";
2264            }
2265            if self.encrypt_by_default {
2266                out += "encrypt_by_default = true\n";
2267            }
2268            if self.reply_sign {
2269                out += "reply_sign = true\n";
2270            }
2271            if self.reply_encrypt {
2272                out += "reply_encrypt = true\n";
2273            }
2274            if self.reply_sign_encrypted {
2275                out += "reply_sign_encrypted = true\n";
2276            }
2277        }
2278        let mut skipped = self.skipped.clone();
2279        if imap.is_some() || self.smtp_url.is_some() {
2280            out += &self.account_toml(imap, &mut skipped);
2281        } else if self.imap_pass.is_some() || self.smtp_pass.is_some() {
2282            skipped.push(
2283                "set imap_pass/smtp_pass = (redacted)  (no IMAP/SMTP server, nowhere to put it)"
2284                    .into(),
2285            );
2286        }
2287        if !self.satisfied.is_empty() {
2288            out += "\n# satisfied by rmut's defaults (nothing to configure):\n";
2289            for s in &self.satisfied {
2290                out += &format!("#   {s}\n");
2291            }
2292        }
2293        if !self.differs.is_empty() {
2294            out += "\n# rmut does these its own way:\n";
2295            for s in &self.differs {
2296                out += &format!("#   {s}\n");
2297            }
2298        }
2299        if !skipped.is_empty() {
2300            out += "\n# not imported:\n";
2301            for s in &skipped {
2302                out += &format!("#   {s}\n");
2303            }
2304        }
2305        out
2306    }
2307
2308    /// The password lines of the account: imap_pass, or smtp_pass when
2309    /// it is the only one given, or a placeholder to fill in.
2310    fn password_toml(&self, out: &mut String, skipped: &mut Vec<String>) {
2311        match (&self.imap_pass, &self.smtp_pass) {
2312            (Some(a), Some(b)) if a != b => {
2313                *out += "# imported from imap_pass; consider password_command instead\n";
2314                *out += &format!("password = {}\n", quote(a));
2315                skipped.push(
2316                    "set smtp_pass = (redacted)  (differs from imap_pass; rmut uses one password per account)"
2317                        .into(),
2318                );
2319            }
2320            (Some(pass), _) | (None, Some(pass)) => {
2321                *out += "# imported from imap_pass/smtp_pass; consider password_command instead\n";
2322                *out += &format!("password = {}\n", quote(pass));
2323            }
2324            (None, None) => {
2325                *out += "# TODO: set a command that prints the password (or password = \"...\"):\n";
2326                *out += "password_command = \"pass show mail/TODO\"\n";
2327            }
2328        }
2329    }
2330
2331    fn account_toml(&self, folder_url: Option<&str>, skipped: &mut Vec<String>) -> String {
2332        let mut out = format!("\n[[accounts]]\nname = {}\n", quote(ACCOUNT));
2333        // imap_user wins; then the user@ part of the folder/smtp URL.
2334        let url_user = folder_url
2335            .into_iter()
2336            .chain(self.smtp_url.as_deref())
2337            .find_map(|url| split_url(url).0.map(str::to_string));
2338        let user = self
2339            .imap_user
2340            .clone()
2341            .or(url_user)
2342            .or_else(|| self.email.clone())
2343            .unwrap_or_else(|| "TODO".into());
2344        out += &format!("user = {}\n", quote(&user));
2345        if let Some(mech) = &self.oauth {
2346            out += &format!("auth = {}\n", quote(mech));
2347            out += "# TODO: set a command that prints a fresh access token\n";
2348            out += "# (oauth2ms, mutt_oauth2.py, ...):\n";
2349            out += "token_command = \"oauth2ms\"\n";
2350            if self.imap_pass.is_some() || self.smtp_pass.is_some() {
2351                skipped.push(
2352                    "set imap_pass/smtp_pass = (redacted)  (the account authenticates with OAuth)"
2353                        .into(),
2354                );
2355            }
2356        } else {
2357            // One credential per account: imap_pass, or smtp_pass when
2358            // it is the only one given.
2359            self.password_toml(&mut out, skipped);
2360        }
2361        if let Some(url) = folder_url {
2362            let (_, host, port, tls) = split_url(url);
2363            out += &format!("imap_host = {}\n", quote(host));
2364            // imap:// means the standard port with STARTTLS (rmut
2365            // upgrades any non-993 port), never a plaintext connection.
2366            match (port, tls) {
2367                (Some(p), _) => out += &format!("imap_port = {p}\n"),
2368                (None, false) => out += "imap_port = 143\n",
2369                (None, true) => {}
2370            }
2371        }
2372        if let Some(smtp) = &self.smtp_url {
2373            let (_, host, port, tls) = split_url(smtp);
2374            out += &format!("smtp_host = {}\n", quote(host));
2375            match (port, tls) {
2376                (Some(p), _) => out += &format!("smtp_port = {p}\n"),
2377                // smtps:// default is implicit TLS on 465.
2378                (None, true) => out += "smtp_port = 465\n",
2379                (None, false) => {}
2380            }
2381        }
2382        if folder_url.is_some()
2383            && let Some(sent) = &self.sent
2384        {
2385            let folder = if is_imap_url(sent) {
2386                url_mailbox(sent)
2387            } else {
2388                sent.trim_start_matches(['+', '=']).trim_matches('/').into()
2389            };
2390            out += &format!("sent_folder = {}\n", quote(&folder));
2391        }
2392        out
2393    }
2394}
2395
2396fn is_imap_url(value: &str) -> bool {
2397    value.starts_with("imap://") || value.starts_with("imaps://")
2398}
2399
2400/// userinfo, host, explicit port, and whether the scheme implies TLS.
2401/// Tolerates a trailing empty port ("host:"); any mailbox path in the
2402/// URL is ignored here (see `url_mailbox`).
2403fn split_url(url: &str) -> (Option<&str>, &str, Option<u16>, bool) {
2404    let (tls, rest) = match url.split_once("://") {
2405        Some((scheme, rest)) => (scheme.ends_with('s'), rest),
2406        None => (true, url),
2407    };
2408    let rest = rest.split('/').next().unwrap_or(rest);
2409    let (user, rest) = match rest.rsplit_once('@') {
2410        Some((u, r)) if !u.is_empty() => (Some(u), r),
2411        _ => (None, rest),
2412    };
2413    match rest.rsplit_once(':') {
2414        Some((host, port)) if !host.is_empty() => (user, host, port.parse().ok(), tls),
2415        _ => (user, rest, None, tls),
2416    }
2417}
2418
2419/// The mailbox named by an imap[s]:// URL's path; INBOX when absent.
2420fn url_mailbox(url: &str) -> String {
2421    let rest = url.split_once("://").map_or(url, |(_, r)| r);
2422    let path = rest
2423        .split_once('/')
2424        .map_or("", |(_, p)| p)
2425        .trim_matches('/');
2426    if path.is_empty() {
2427        "INBOX".into()
2428    } else {
2429        path.to_string()
2430    }
2431}
2432
2433fn quote(value: &str) -> String {
2434    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
2435}
2436
2437/// mutt key syntax → rmut key syntax (None: no equivalent).
2438pub fn convert_key(key: &str) -> Option<String> {
2439    if let Some(c) = key.strip_prefix("\\C").or_else(|| key.strip_prefix("\\c")) {
2440        let mut chars = c.chars();
2441        let c = chars.next()?;
2442        return chars
2443            .next()
2444            .is_none()
2445            .then(|| format!("ctrl+{}", c.to_ascii_lowercase()));
2446    }
2447    if let Some(c) = key
2448        .strip_prefix("\\e")
2449        .or_else(|| key.strip_prefix("<esc>").filter(|rest| !rest.is_empty()))
2450    {
2451        let mut chars = c.chars();
2452        let c = chars.next()?;
2453        return chars.next().is_none().then(|| format!("alt+{c}"));
2454    }
2455    let named = match key.to_lowercase().as_str() {
2456        "<return>" | "<enter>" => "enter",
2457        "<esc>" => "esc",
2458        "<space>" => "space",
2459        "<tab>" => "tab",
2460        "<backspace>" => "backspace",
2461        "<up>" => "up",
2462        "<down>" => "down",
2463        "<pageup>" => "pgup",
2464        "<pagedown>" => "pgdn",
2465        "<home>" => "home",
2466        "<end>" => "end",
2467        _ => {
2468            let mut chars = key.chars();
2469            let c = chars.next()?;
2470            return (chars.next().is_none() && c != '<').then(|| c.to_string());
2471        }
2472    };
2473    Some(named.to_string())
2474}
2475
2476/// A mutt macro sequence in rmut's syntax: literal characters,
2477/// `<key-name>`s rmut knows, and `\`-escapes (`\n` `\t` `\e` `\Cx`).
2478/// None when it names mutt functions or exotic keys.
2479fn convert_sequence(seq: &str) -> Option<String> {
2480    const NAMES: [&str; 15] = [
2481        "enter",
2482        "return",
2483        "esc",
2484        "escape",
2485        "space",
2486        "tab",
2487        "backspace",
2488        "up",
2489        "down",
2490        "pgup",
2491        "pageup",
2492        "pgdn",
2493        "pagedown",
2494        "home",
2495        "end",
2496    ];
2497    let mut out = String::new();
2498    let mut chars = seq.chars();
2499    while let Some(c) = chars.next() {
2500        match c {
2501            '<' => {
2502                let mut name = String::new();
2503                loop {
2504                    match chars.next() {
2505                        Some('>') => break,
2506                        Some(c) => name.push(c),
2507                        None => return None,
2508                    }
2509                }
2510                let name = name.to_lowercase();
2511                if !NAMES.contains(&name.as_str()) {
2512                    return None; // a mutt function name
2513                }
2514                out += &format!("<{name}>");
2515            }
2516            '\\' => match chars.next()? {
2517                'n' | 'r' => out += "<enter>",
2518                't' => out += "<tab>",
2519                'e' => out += "<esc>",
2520                'c' | 'C' => out += &format!("<ctrl+{}>", chars.next()?.to_ascii_lowercase()),
2521                other => out.push(other),
2522            },
2523            c => out.push(c),
2524        }
2525    }
2526    Some(out)
2527}
2528
2529/// mutt "bright" colors are close to ratatui's "light" family.
2530pub(crate) fn convert_color(name: &str) -> String {
2531    match name.strip_prefix("bright") {
2532        Some(base) => format!("light{base}"),
2533        None => name.to_string(),
2534    }
2535}
2536
2537pub fn index_function(name: &str) -> Option<&'static str> {
2538    Some(match name {
2539        "quit" => "quit",
2540        "exit" => "abort",
2541        "next-entry" | "next-undeleted" => "down",
2542        "previous-entry" | "previous-undeleted" => "up",
2543        "next-page" => "page-down",
2544        "previous-page" => "page-up",
2545        "first-entry" => "first",
2546        "last-entry" => "last",
2547        "display-message" => "view",
2548        "delete-message" => "delete",
2549        "undelete-message" => "undelete",
2550        "flag-message" => "flag",
2551        "toggle-new" => "toggle-new",
2552        "sync-mailbox" => "sync",
2553        "mail" => "compose",
2554        "reply" => "reply",
2555        "group-reply" => "group-reply",
2556        "forward-message" => "forward",
2557        "sort-mailbox" => "sort",
2558        "limit" => "limit",
2559        "search" => "search",
2560        "search-next" => "search-next",
2561        "next-new" | "next-new-then-unread" | "next-unread" => "next-new",
2562        "previous-new" | "previous-new-then-unread" | "previous-unread" => "previous-new",
2563        "delete-pattern" => "delete-pattern",
2564        "undelete-pattern" => "undelete-pattern",
2565        "tag-pattern" => "tag-pattern",
2566        "untag-pattern" => "untag-pattern",
2567        "change-folder" => "change-mailbox",
2568        "view-attachments" => "attachments",
2569        "collapse-thread" => "fold-thread",
2570        "collapse-all" => "fold-all",
2571        // The thread functions, under mutt's own names.
2572        "delete-thread" => "delete-thread",
2573        "undelete-thread" => "undelete-thread",
2574        "tag-thread" => "tag-thread",
2575        "delete-subthread" => "delete-subthread",
2576        "undelete-subthread" => "undelete-subthread",
2577        "next-thread" => "next-thread",
2578        "previous-thread" => "previous-thread",
2579        "break-thread" => "break-thread",
2580        "link-threads" => "link-threads",
2581        "read-thread" => "read-thread",
2582        "read-subthread" => "read-subthread",
2583        "tag-subthread" => "tag-subthread",
2584        "parent-message" => "parent-message",
2585        "root-message" => "root-message",
2586        "imap-fetch-mail" | "fetch-mail" => "fetch-mail",
2587        "tag-entry" | "tag-message" => "tag",
2588        "tag-prefix" => "tag-prefix",
2589        "query" => "query",
2590        "save-message" => "save",
2591        "decode-save" => "decode-save",
2592        "decode-copy" => "decode-copy",
2593        "print-message" => "print",
2594        "edit" => "edit",
2595        "resend-message" => "resend",
2596        "edit-label" => "edit-label",
2597        "show-version" => "show-version",
2598        "show-limit" => "show-limit",
2599        "display-address" => "display-address",
2600        "toggle-write" => "toggle-write",
2601        "top-page" => "top-page",
2602        "middle-page" => "middle-page",
2603        "bottom-page" => "bottom-page",
2604        "help" => "help",
2605        _ => return None,
2606    })
2607}
2608
2609pub fn pager_function(name: &str) -> Option<&'static str> {
2610    Some(match name {
2611        "exit" => "back",
2612        "next-line" => "down",
2613        "previous-line" => "up",
2614        "next-page" => "page-down",
2615        "previous-page" => "page-up",
2616        "half-down" => "half-down",
2617        "half-up" => "half-up",
2618        "top" => "top",
2619        "bottom" => "bottom",
2620        "toggle-quoted" => "toggle-quoted",
2621        "skip-quoted" => "skip-quoted",
2622        "next-entry" => "next",
2623        "previous-entry" => "previous",
2624        "next-undeleted" => "next-undeleted",
2625        "previous-undeleted" => "previous-undeleted",
2626        "delete-message" => "delete",
2627        "display-toggle-weed" => "headers",
2628        "search" => "search",
2629        "search-next" => "search-next",
2630        "search-opposite" => "search-prev",
2631        "search-toggle" => "search-toggle",
2632        "view-attachments" => "attachments",
2633        "mail" => "compose",
2634        "reply" => "reply",
2635        "group-reply" => "group-reply",
2636        "forward-message" => "forward",
2637        "save-message" => "save",
2638        "print-message" => "print",
2639        "edit" => "edit",
2640        "resend-message" => "resend",
2641        "help" => "help",
2642        _ => return None,
2643    })
2644}
2645
2646#[cfg(test)]
2647mod tests {
2648    use super::*;
2649    use crate::config::Config;
2650
2651    fn to_config(muttrc: &str) -> (Config, String) {
2652        let import = import(muttrc, Path::new("/nonexistent"));
2653        let cfg: Config = toml::from_str(&import.toml)
2654            .unwrap_or_else(|e| panic!("bad TOML: {e}\n{}", import.toml));
2655        (cfg, import.toml)
2656    }
2657
2658    #[test]
2659    fn format_flowed_options_import() {
2660        let (cfg, toml) = to_config("set text_flowed\nset noreflow_text\n");
2661        assert!(cfg.mail.text_flowed);
2662        assert_eq!(cfg.pager.reflow_text, Some(false));
2663        assert!(toml.contains("text_flowed = true"), "{toml}");
2664        assert!(toml.contains("reflow_text = false"), "{toml}");
2665        // Both defaults match rmut's, so an explicit default is quiet.
2666        let (cfg, _) = to_config("set notext_flowed\nset reflow_text\n");
2667        assert!(!cfg.mail.text_flowed);
2668        assert_eq!(cfg.pager.reflow_text, None);
2669    }
2670
2671    #[test]
2672    fn index_colors_keep_a_background() {
2673        let (cfg, toml) = to_config(concat!(
2674            "color index black magenta \"~D\"\n",
2675            "color index brightred default \"~F\"\n",
2676        ));
2677        // A background means mutt painted a bar: both colours stay,
2678        // as a [[color_index]] rule.
2679        let deleted = cfg.color_index.iter().find(|r| r.pattern == "~D").unwrap();
2680        assert_eq!(deleted.fg.as_deref(), Some("black"));
2681        assert_eq!(deleted.bg.as_deref(), Some("magenta"));
2682        // No background: one visible colour, which is what the
2683        // built-in slot holds.
2684        assert_eq!(
2685            cfg.colors.get("flagged").map(String::as_str),
2686            Some("lightred")
2687        );
2688        assert!(toml.contains("[[color_index]]"), "{toml}");
2689    }
2690
2691    #[test]
2692    fn attachment_reminder_imports() {
2693        let (cfg, toml) = to_config(concat!(
2694            "set abort_noattach = ask-yes\n",
2695            "set abort_noattach_regex = \"\\\\<(attach|pripojen)\"\n",
2696        ));
2697        // ask-yes and ask-no are the same question to rmut.
2698        assert_eq!(cfg.mail.abort_noattach.as_deref(), Some("ask"));
2699        assert_eq!(
2700            cfg.mail.attach_keyword.as_deref(),
2701            Some("\\b(attach|pripojen)")
2702        );
2703        assert!(toml.contains("abort_noattach"), "{toml}");
2704        let (cfg, _) = to_config("set abort_noattach = yes\n");
2705        assert_eq!(cfg.mail.abort_noattach.as_deref(), Some("yes"));
2706    }
2707
2708    #[test]
2709    fn hooks_round_two_translate() {
2710        let (cfg, toml) = to_config(concat!(
2711            "folder-hook work 'set index_format=\"%s\"'\n",
2712            "folder-hook . 'set sort=threads'\n",
2713            "message-hook '~f boss@example\\.com' 'set pager_context=5'\n",
2714            "reply-hook '~t @work\\.example\\.com' 'set from=jane@work.example.com'\n",
2715            "fcc-hook '~t @work\\.example\\.com' +work-sent\n",
2716            "fcc-save-hook boss@example.com +boss\n",
2717            "crypt-hook boss@example.com 0xDEADBEEF\n",
2718            "message-hook '~X 3' 'set beep'\n", // unparseable pattern
2719            "reply-hook '~s x' 'frobnicate'\n", // unknown command
2720        ));
2721        assert_eq!(cfg.folder_hooks.len(), 2);
2722        assert_eq!(cfg.folder_hooks[0].folder, "*work*");
2723        assert_eq!(cfg.folder_hooks[0].command, "set index_format=\"%s\"");
2724        assert_eq!(cfg.folder_hooks[1].folder, "*");
2725        assert_eq!(cfg.message_hooks.len(), 1);
2726        assert_eq!(cfg.message_hooks[0].command, "set pager_context=5");
2727        assert_eq!(cfg.reply_hooks.len(), 1);
2728        assert_eq!(cfg.reply_hooks[0].pattern, "~t @work\\.example\\.com");
2729        assert_eq!(cfg.fcc_hooks.len(), 2);
2730        assert_eq!(cfg.fcc_hooks[0].mailbox, "work-sent");
2731        // A bare fcc-hook pattern gets mutt's $default_hook expansion.
2732        assert_eq!(
2733            cfg.fcc_hooks[1].pattern,
2734            "(~f \"boss@example.com\" !~P) | (~P ~C \"boss@example.com\")"
2735        );
2736        assert_eq!(cfg.crypt_hooks.len(), 1);
2737        assert_eq!(cfg.crypt_hooks[0].key, "0xDEADBEEF");
2738        // The two broken hooks stay visible as comments.
2739        assert!(toml.contains("pattern does not translate"), "{toml}");
2740        assert!(toml.contains("frobnicate"), "{toml}");
2741    }
2742
2743    #[test]
2744    fn default_hook_expansion() {
2745        assert_eq!(default_hook_pattern("."), "~A");
2746        assert_eq!(default_hook_pattern("~t x"), "~t x");
2747        assert_eq!(default_hook_pattern("!~P"), "!~P");
2748        assert_eq!(
2749            default_hook_pattern("a@b"),
2750            "(~f \"a@b\" !~P) | (~P ~C \"a@b\")"
2751        );
2752    }
2753
2754    #[test]
2755    fn alternates_and_my_hdr_import() {
2756        let (cfg, toml) = to_config(concat!(
2757            "alternates jane@old\\.example\\.com '@club\\.example\\.com$'\n",
2758            "alternates typo@example\\.com\n",
2759            "unalternates typo@example\\.com\n",
2760            "my_hdr Organization: Acme\n",
2761            "my_hdr X-Mailer: rmut\n",
2762            "my_hdr Organization: Acme Ltd\n",
2763            "unmy_hdr X-Mailer\n",
2764            "set metoo\n",
2765        ));
2766        assert_eq!(
2767            cfg.mail.alternates,
2768            ["jane@old\\.example\\.com", "@club\\.example\\.com$"]
2769        );
2770        // One entry per header name: the later Organization wins, and
2771        // unmy_hdr takes X-Mailer back out.
2772        assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
2773        assert!(cfg.mail.metoo);
2774        assert!(toml.contains("alternates = ["), "{toml}");
2775        assert!(toml.contains("metoo = true"), "{toml}");
2776    }
2777
2778    #[test]
2779    fn hooks_become_identity_rules() {
2780        let (cfg, toml) = to_config(concat!(
2781            "set reverse_name = yes\n",
2782            "folder-hook work 'set from=\"Jane Work <jane@work.example.com>\"'\n",
2783            "send-hook '~t @club\\.example\\.com' 'set realname=\"Jenny\"'\n",
2784            "folder-hook . 'push <collapse-all>'\n",
2785            "send-hook '~l' 'set from=list@example.com'\n",
2786        ));
2787        assert!(cfg.identity.reverse_name);
2788        assert_eq!(cfg.identities.len(), 2);
2789        let work = &cfg.identities[0];
2790        assert_eq!(work.folder.as_deref(), Some("*work*"));
2791        assert!(work.recipient.is_none());
2792        assert_eq!(work.name.as_deref(), Some("Jane Work"));
2793        assert_eq!(work.email.as_deref(), Some("jane@work.example.com"));
2794        let club = &cfg.identities[1];
2795        assert_eq!(club.recipient.as_deref(), Some("*@club.example.com*"));
2796        assert!(club.folder.is_none());
2797        assert_eq!(club.name.as_deref(), Some("Jenny"));
2798        // A folder-hook that is not an identity set becomes a
2799        // [[folder_hooks]] line; the ~l send-hook stays a comment.
2800        assert_eq!(cfg.folder_hooks.len(), 1);
2801        assert_eq!(cfg.folder_hooks[0].folder, "*");
2802        assert_eq!(cfg.folder_hooks[0].command, "push <collapse-all>");
2803        assert!(toml.contains("does not translate to a glob"), "{toml}");
2804        // reverse_name off matches rmut's default.
2805        let (cfg, toml) = to_config("set reverse_name = no\n");
2806        assert!(!cfg.identity.reverse_name);
2807        assert!(toml.contains("# satisfied"), "{toml}");
2808    }
2809
2810    #[test]
2811    fn oauth_authenticators_become_account_auth() {
2812        let (cfg, toml) = to_config(concat!(
2813            "set folder = \"imaps://outlook.example.com/\"\n",
2814            "set imap_user = \"jane@example.com\"\n",
2815            "set imap_pass = \"hunter2\"\n",
2816            "set imap_authenticators = \"oauthbearer\"\n",
2817            "set smtp_authenticators = \"xoauth2:plain\"\n",
2818        ));
2819        let account = cfg.account("mutt").unwrap();
2820        // The last-seen mechanism wins; both directives name OAuth.
2821        assert_eq!(account.auth.as_deref(), Some("xoauth2"));
2822        assert_eq!(account.token_command.as_deref(), Some("oauth2ms"));
2823        // The stored password is not emitted alongside OAuth.
2824        assert!(account.password.is_none());
2825        assert!(!toml.contains("hunter2"), "{toml}");
2826        assert!(toml.contains("the account authenticates with OAuth"));
2827        assert!(toml.contains("fresh access token"), "{toml}");
2828        // Plain-only stays satisfied, unknown mechanisms stay skipped.
2829        let (_, toml) = to_config("set smtp_authenticators = \"plain\"\n");
2830        assert!(toml.contains("# satisfied"), "{toml}");
2831        let (_, toml) = to_config("set smtp_authenticators = \"gssapi\"\n");
2832        assert!(toml.contains("xoauth2/oauthbearer"), "{toml}");
2833    }
2834
2835    #[test]
2836    fn color_index_patterns_and_status_format_import() {
2837        let (cfg, toml) = to_config(concat!(
2838            "color index yellow default \"~f boss@example.com\"\n",
2839            "color index brightred blue \"~d <1w ~U\"\n",
2840            "color index red default ~D\n",         // still a slot
2841            "color index green default \"~X 3\"\n", // unparseable
2842            "set status_format = \"-%r- %f [%m msgs%?t?, %t tagged?]\"\n",
2843        ));
2844        assert_eq!(cfg.color_index.len(), 2);
2845        assert_eq!(cfg.color_index[0].pattern, "~f boss@example.com");
2846        assert_eq!(cfg.color_index[0].fg.as_deref(), Some("yellow"));
2847        assert!(cfg.color_index[0].bg.is_none()); // "default" dropped
2848        assert_eq!(cfg.color_index[1].fg.as_deref(), Some("lightred"));
2849        assert_eq!(cfg.color_index[1].bg.as_deref(), Some("blue"));
2850        assert_eq!(cfg.colors.get("deleted").map(String::as_str), Some("red"));
2851        assert_eq!(
2852            cfg.ui.status_format.as_deref(),
2853            Some("-%r- %f [%m msgs%?t?, %t tagged?]")
2854        );
2855        assert!(toml.contains("no rmut color slot"), "{toml}");
2856    }
2857
2858    #[test]
2859    fn macros_translate_plain_sequences() {
2860        let (cfg, toml) = to_config(concat!(
2861            "macro index L \"l~f jane\\n\" \"limit to jane\"\n",
2862            "macro index,pager \\Cs \"~s urgent<Enter>\"\n",
2863            "macro index A \"<collapse-all>\"\n",
2864            "macro compose X \"y\"\n",
2865        ));
2866        assert_eq!(
2867            cfg.macros.index.get("L").map(String::as_str),
2868            Some("l~f jane<enter>")
2869        );
2870        assert_eq!(
2871            cfg.macros.index.get("ctrl+s").map(String::as_str),
2872            Some("~s urgent<enter>")
2873        );
2874        assert_eq!(
2875            cfg.macros.pager.get("ctrl+s").map(String::as_str),
2876            Some("~s urgent<enter>")
2877        );
2878        // Function names and foreign menus stay visible as comments.
2879        assert!(toml.contains("mutt function names do not"), "{toml}");
2880        assert!(toml.contains("only index and pager menus"), "{toml}");
2881    }
2882
2883    #[test]
2884    fn hook_glob_shapes() {
2885        assert_eq!(hook_glob(".").as_deref(), Some("*"));
2886        assert_eq!(hook_glob("work").as_deref(), Some("*work*"));
2887        assert_eq!(hook_glob("^/mail/work$").as_deref(), Some("/mail/work"));
2888        assert_eq!(hook_glob("=lists").as_deref(), Some("*lists*"));
2889        assert_eq!(
2890            hook_glob("~t bob@example\\.com").as_deref(),
2891            Some("*bob@example.com*")
2892        );
2893        assert_eq!(hook_glob("work.*").as_deref(), Some("*work*"));
2894        assert!(hook_glob("(a|b)").is_none());
2895        assert!(hook_glob("~f jane").is_none());
2896    }
2897
2898    #[test]
2899    fn set_forms_and_identity() {
2900        let (cfg, _) = to_config(concat!(
2901            "set realname = \"Jane Doe\"\n",
2902            "set from=jane@example.com\n",
2903            "set editor = vim  # trailing comment\n",
2904            "set mail_check=30\n",
2905        ));
2906        assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
2907        assert_eq!(cfg.identity.email.as_deref(), Some("jane@example.com"));
2908        assert_eq!(cfg.mail.editor.as_deref(), Some("vim"));
2909        assert_eq!(cfg.mail.poll_seconds, Some(30));
2910    }
2911
2912    #[test]
2913    fn from_with_display_name_fills_both() {
2914        let (cfg, _) = to_config("set from = \"Jane Doe <jane@x.org>\"\n");
2915        assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
2916        assert_eq!(cfg.identity.email.as_deref(), Some("jane@x.org"));
2917        // explicit realname wins over the From display name
2918        let (cfg, _) = to_config("set realname=RN\nset from = \"DN <j@x>\"\n");
2919        assert_eq!(cfg.identity.name.as_deref(), Some("RN"));
2920    }
2921
2922    #[test]
2923    fn mailboxes_expand_against_folder() {
2924        let (cfg, _) = to_config(concat!(
2925            "set folder = ~/Mail\n",
2926            "set spoolfile = +inbox\n",
2927            "set record = +sent\n",
2928            "set postponed = +drafts\n",
2929            "mailboxes +inbox +work ~/other\n",
2930        ));
2931        assert_eq!(
2932            cfg.mail.mailboxes,
2933            vec!["~/Mail/inbox", "~/Mail/work", "~/other"]
2934        );
2935        assert_eq!(cfg.mail.sent.as_deref(), Some("~/Mail/sent"));
2936        assert_eq!(cfg.mail.postponed.as_deref(), Some("~/Mail/drafts"));
2937        // $folder carries over, so rmut expands +x / =x at runtime as
2938        // well: an imported macro can still say "=archive".
2939        assert_eq!(cfg.mail.folder.as_deref(), Some("~/Mail"));
2940    }
2941
2942    #[test]
2943    fn binds_translate_keys_and_functions() {
2944        let (cfg, toml) = to_config(concat!(
2945            "bind index \\Cd delete-message\n",
2946            "bind index <esc>s sync-mailbox\n",
2947            "bind pager <space> next-page\n",
2948            "bind index,pager R group-reply\n",
2949            "bind index gg first-entry\n", // multi-key: skipped
2950            "bind index Z frobnicate\n",   // unknown function: skipped
2951        ));
2952        assert_eq!(
2953            cfg.keys.index.get("delete").map(String::as_str),
2954            Some("ctrl+d")
2955        );
2956        assert_eq!(
2957            cfg.keys.index.get("sync").map(String::as_str),
2958            Some("alt+s")
2959        );
2960        assert_eq!(
2961            cfg.keys.pager.get("page-down").map(String::as_str),
2962            Some("space")
2963        );
2964        assert_eq!(
2965            cfg.keys.index.get("group-reply").map(String::as_str),
2966            Some("R")
2967        );
2968        assert_eq!(
2969            cfg.keys.pager.get("group-reply").map(String::as_str),
2970            Some("R")
2971        );
2972        assert!(toml.contains("# not imported:"));
2973        assert!(toml.contains("bind index gg first-entry"));
2974        assert!(toml.contains("bind index Z frobnicate"));
2975    }
2976
2977    #[test]
2978    fn colors_map_to_rmut_slots() {
2979        let (cfg, toml) = to_config(concat!(
2980            "color status brightyellow blue\n",
2981            "color header cyan default\n",
2982            "color index red default ~D\n",
2983            "color index brightmagenta default ~F\n",
2984            "color indicator black white\n", // no slot: skipped
2985        ));
2986        assert_eq!(
2987            cfg.colors.get("status_fg").map(String::as_str),
2988            Some("lightyellow")
2989        );
2990        assert_eq!(
2991            cfg.colors.get("status_bg").map(String::as_str),
2992            Some("blue")
2993        );
2994        assert_eq!(cfg.colors.get("header").map(String::as_str), Some("cyan"));
2995        assert_eq!(cfg.colors.get("deleted").map(String::as_str), Some("red"));
2996        assert_eq!(
2997            cfg.colors.get("flagged").map(String::as_str),
2998            Some("lightmagenta")
2999        );
3000        assert!(toml.contains("color indicator"));
3001    }
3002
3003    #[test]
3004    fn pager_colors_and_motion_translate() {
3005        let (cfg, toml) = to_config(concat!(
3006            "color quoted cyan default\n",
3007            "color quoted1 yellow default\n",
3008            "color body magenta default \"https?://[^ ]+\"\n",
3009            "color search black yellow\n",
3010            "set quote_regexp=\"^( *[>|])+\"\n",
3011            "bind pager \\Cd half-down\n",
3012            "bind pager T toggle-quoted\n",
3013            "bind pager S skip-quoted\n",
3014        ));
3015        assert_eq!(cfg.colors.get("quoted").map(String::as_str), Some("cyan"));
3016        assert_eq!(
3017            cfg.colors.get("quoted1").map(String::as_str),
3018            Some("yellow")
3019        );
3020        assert_eq!(
3021            cfg.colors.get("search_bg").map(String::as_str),
3022            Some("yellow")
3023        );
3024        assert_eq!(cfg.color_body.len(), 1);
3025        assert_eq!(cfg.color_body[0].pattern, "https?://[^ ]+");
3026        assert_eq!(cfg.color_body[0].fg.as_deref(), Some("magenta"));
3027        assert_eq!(cfg.pager.quote_regexp.as_deref(), Some("^( *[>|])+"));
3028        assert_eq!(
3029            cfg.keys.pager.get("half-down").map(String::as_str),
3030            Some("ctrl+d")
3031        );
3032        assert_eq!(
3033            cfg.keys.pager.get("toggle-quoted").map(String::as_str),
3034            Some("T")
3035        );
3036        assert!(toml.contains("[[color_body]]"));
3037    }
3038
3039    #[test]
3040    fn header_weeding_and_pager_polish_translate() {
3041        let (cfg, toml) = to_config(concat!(
3042            "ignore *\n",
3043            "unignore from date subject\n",
3044            "hdr_order Date: From: Subject:\n",
3045            "set pager_format=\"-%Z- %C/%m: %s\"\n",
3046            "set wrap = 78\n",
3047            "set tilde\n",
3048        ));
3049        assert_eq!(cfg.pager.ignore.as_deref(), Some(&["*".to_string()][..]));
3050        assert_eq!(
3051            cfg.pager.unignore.as_deref(),
3052            Some(&["from".to_string(), "date".into(), "subject".into()][..])
3053        );
3054        assert_eq!(
3055            cfg.pager.hdr_order.as_deref(),
3056            Some(&["Date".to_string(), "From".into(), "Subject".into()][..])
3057        );
3058        assert_eq!(cfg.pager.format.as_deref(), Some("-%Z- %C/%m: %s"));
3059        assert_eq!(cfg.pager.wrap, Some(78));
3060        assert!(cfg.pager.tilde);
3061        assert!(toml.contains("hdr_order"));
3062    }
3063
3064    #[test]
3065    fn compose_round_two_translates() {
3066        let (cfg, _) = to_config(concat!(
3067            "set fast_reply = yes\n",
3068            "set autoedit\n",
3069            "set nocopy\n",
3070            "set mime_forward = ask-yes\n",
3071            "set forward_decode\n", // satisfied: always decoded
3072            "set new_mail_command=\"notify-send 'rmut: %n new in %f'\"\n",
3073        ));
3074        assert!(cfg.mail.fast_reply);
3075        assert!(cfg.mail.autoedit);
3076        assert_eq!(cfg.mail.copy, Some(false));
3077        assert_eq!(cfg.mail.forward.as_deref(), Some("ask"));
3078        assert_eq!(
3079            cfg.mail.new_mail_command.as_deref(),
3080            Some("notify-send 'rmut: %n new in %f'")
3081        );
3082    }
3083
3084    #[test]
3085    fn the_signature_and_the_send_questions_import() {
3086        let (cfg, toml) = to_config(concat!(
3087            "set signature = \"~/.signature\"\n",
3088            "set nosig_dashes\n",
3089            "set forward_quote\n",
3090            "set abort_nosubject = no\n",
3091            "set noabort_unmodified\n",
3092        ));
3093        assert_eq!(cfg.mail.signature.as_deref(), Some("~/.signature"));
3094        assert_eq!(cfg.mail.sig_dashes, Some(false));
3095        assert!(cfg.mail.forward_quote);
3096        assert_eq!(cfg.mail.abort_nosubject.as_deref(), Some("no"));
3097        assert_eq!(cfg.mail.abort_unmodified, Some(false));
3098        assert!(toml.contains("signature = \"~/.signature\""), "{toml}");
3099    }
3100
3101    #[test]
3102    fn what_rmut_already_asks_is_satisfied_not_skipped() {
3103        // The mutt defaults for these four are rmut's behaviour, so
3104        // they belong in neither the config nor the skipped list.
3105        let (cfg, toml) = to_config(concat!(
3106            "set reply_to = ask-yes\n",
3107            "set honor_followup_to = yes\n",
3108            "set abort_nosubject = ask-yes\n",
3109            "set abort_unmodified = yes\n",
3110            "set sig_dashes = yes\n",
3111            "set noforward_quote\n",
3112        ));
3113        assert_eq!(cfg.mail.abort_nosubject, None);
3114        assert_eq!(cfg.mail.abort_unmodified, None);
3115        assert_eq!(cfg.mail.sig_dashes, None);
3116        assert!(!cfg.mail.forward_quote);
3117        let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3118        assert!(unclaimed.trim().is_empty(), "{toml}");
3119        assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3120        // reply_to's other three are rmut answering it for you.
3121        let (_, toml) = to_config("set reply_to = yes\n");
3122        let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3123        assert!(unclaimed.contains("set reply_to = yes"), "{toml}");
3124    }
3125
3126    #[test]
3127    fn the_small_habits_import_or_are_already_rmut() {
3128        let (cfg, toml) = to_config(concat!(
3129            "set nomark_old\n",
3130            "set beep_new = yes\n",
3131            "set nowait_key\n",
3132            "set print = ask-yes\n",
3133            "set noreverse_realname\n",
3134        ));
3135        assert_eq!(cfg.mail.mark_old, Some(false));
3136        assert!(cfg.ui.beep_new);
3137        assert_eq!(cfg.ui.wait_key, Some(false));
3138        assert_eq!(cfg.mail.print_confirm.as_deref(), Some("ask-yes"));
3139        assert_eq!(cfg.identity.reverse_realname, Some(false));
3140        assert!(toml.contains("beep_new = true"), "{toml}");
3141
3142        // The mutt defaults for all five are what rmut does anyway,
3143        // and $timeout is a poll rmut runs its own way.
3144        let (_, toml) = to_config(concat!(
3145            "set mark_old = yes\n",
3146            "set nobeep_new\n",
3147            "set wait_key = yes\n",
3148            "set print = ask-no\n",
3149            "set reverse_realname = yes\n",
3150            "set timeout = 15\n",
3151        ));
3152        let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3153        assert!(unclaimed.trim().is_empty(), "{toml}");
3154        assert!(toml.contains("#   set timeout = 15  (rmut polls"), "{toml}");
3155    }
3156
3157    #[test]
3158    fn pgp_settings_translate() {
3159        let (cfg, _) = to_config(concat!(
3160            "set pgp_sign_as = 0xDEADBEEF\n",
3161            "set crypt_autosign = yes\n",
3162            "set nocrypt_autoencrypt\n",
3163        ));
3164        assert_eq!(cfg.pgp.sign_key.as_deref(), Some("0xDEADBEEF"));
3165        assert!(cfg.pgp.sign_by_default);
3166        assert!(!cfg.pgp.encrypt_by_default);
3167    }
3168
3169    #[test]
3170    fn what_rmut_has_is_imported_and_what_it_does_its_own_way_is_said() {
3171        let (cfg, toml) = to_config(concat!(
3172            "set sidebar_visible = yes\n",
3173            "set sidebar_width = 24\n",
3174            "set sidebar_format = \"%B%* %N\"\n",
3175            "set alias_file = ~/.mutt/aliases\n",
3176            "set imap_idle = yes\n",
3177            "set header_cache = ~/.cache/mutt\n",
3178            "set crypt_use_gpgme = yes\n",
3179            "set implicit_autoview = yes\n",
3180        ));
3181        // The settings rmut has come across.
3182        assert!(cfg.sidebar.visible, "{toml}");
3183        assert_eq!(cfg.sidebar.width, 24);
3184        assert_eq!(cfg.mail.alias_file.as_deref(), Some("~/.mutt/aliases"));
3185        // The ones it satisfies say so, rather than reading as holes.
3186        assert!(
3187            toml.contains("rmut IDLEs whenever the server offers it"),
3188            "{toml}"
3189        );
3190        assert!(toml.contains("# rmut does these its own way:"), "{toml}");
3191        assert!(toml.contains("under ~/.cache/rmut"), "{toml}");
3192        assert!(toml.contains("gpg(1) directly"), "{toml}");
3193        assert!(toml.contains("no format string"), "{toml}");
3194        // And none of them is claimed as not imported.
3195        let not_imported = toml.split("# not imported:").nth(1).unwrap_or("");
3196        for gone in [
3197            "sidebar_visible",
3198            "imap_idle",
3199            "header_cache",
3200            "crypt_use_gpgme",
3201        ] {
3202            assert!(!not_imported.contains(gone), "{gone} still skipped: {toml}");
3203        }
3204    }
3205
3206    #[test]
3207    fn the_reply_and_forward_text_carries_over() {
3208        let (cfg, toml) = to_config(concat!(
3209            "set attribution = \"On %d, %n wrote:\"\n",
3210            "set indent_string = \"| \"\n",
3211            "set forward_format = \"Fwd: %s\"\n",
3212            "set include = no\n",
3213            "set askcc = yes\n",
3214            "set askbcc = yes\n",
3215        ));
3216        assert_eq!(
3217            cfg.mail.attribution.as_deref(),
3218            Some("On %d, %n wrote:"),
3219            "{toml}"
3220        );
3221        assert_eq!(cfg.mail.indent_string.as_deref(), Some("| "));
3222        assert_eq!(cfg.mail.forward_format.as_deref(), Some("Fwd: %s"));
3223        assert_eq!(cfg.mail.include.as_deref(), Some("no"));
3224        assert!(cfg.mail.ask_cc && cfg.mail.ask_bcc);
3225        // A quadoption rmut cannot make sense of is reported, not
3226        // guessed at.
3227        let import = import("set include = maybe\n", Path::new("/nonexistent"));
3228        assert!(
3229            import
3230                .toml
3231                .contains("include wants yes / no / ask-yes / ask-no"),
3232            "{}",
3233            import.toml
3234        );
3235    }
3236
3237    #[test]
3238    fn connect_timeout_carries_over() {
3239        let (cfg, toml) = to_config("set connect_timeout=15\n");
3240        assert_eq!(cfg.net.connect_timeout, 15, "{toml}");
3241        // mutt waits for the OS when it is zero or less.
3242        let (cfg, _) = to_config("set connect_timeout=-1\n");
3243        assert_eq!(cfg.net.connect_timeout, 0);
3244    }
3245
3246    #[test]
3247    fn wrap_search_off_carries_over() {
3248        let (cfg, toml) = to_config("set nowrap_search\n");
3249        assert_eq!(cfg.mail.wrap_search, Some(false), "{toml}");
3250    }
3251
3252    #[test]
3253    fn simple_search_carries_over() {
3254        let (cfg, toml) = to_config("set simple_search = \"~f %s | ~s %s | ~b %s\"\n");
3255        assert_eq!(
3256            cfg.mail.simple_search.as_deref(),
3257            Some("~f %s | ~s %s | ~b %s"),
3258            "{toml}"
3259        );
3260    }
3261
3262    #[test]
3263    fn search_context_carries_over() {
3264        let (cfg, toml) = to_config("set search_context = 3\n");
3265        assert_eq!(cfg.pager.search_context, 3, "{toml}");
3266    }
3267
3268    #[test]
3269    fn hide_thread_subject_carries_over() {
3270        let (cfg, toml) = to_config("set hide_thread_subject = yes\n");
3271        assert_eq!(cfg.index.hide_thread_subject, Some(true), "{toml}");
3272    }
3273
3274    #[test]
3275    fn mark_options_carry_over() {
3276        // R85: the non-default side of each comes across; the default
3277        // side is satisfied and emits nothing.
3278        let (cfg, toml) = to_config(
3279            "set nodelete_untag\nset flag_safe\nset maildir_trash\n\
3280             set nomail_check_recent\nset nocheck_new\nset nouncollapse_new\n",
3281        );
3282        assert_eq!(cfg.mail.delete_untag, Some(false), "{toml}");
3283        assert!(cfg.mail.flag_safe);
3284        assert!(cfg.mail.maildir_trash);
3285        assert_eq!(cfg.mail.mail_check_recent, Some(false));
3286        assert_eq!(cfg.mail.check_new, Some(false));
3287        assert_eq!(cfg.index.uncollapse_new, Some(false));
3288        let (cfg, toml) = to_config(
3289            "set delete_untag\nset noflag_safe\nset nomaildir_trash\n\
3290             set mail_check_recent\nset check_new\nset uncollapse_new\n",
3291        );
3292        assert_eq!(cfg.mail.delete_untag, None, "{toml}");
3293        assert!(!cfg.mail.flag_safe);
3294        assert!(!cfg.mail.maildir_trash);
3295        assert_eq!(cfg.index.uncollapse_new, None);
3296        assert!(!toml.contains("not imported"), "{toml}");
3297    }
3298
3299    #[test]
3300    fn menu_knobs_carry_over() {
3301        let (cfg, toml) =
3302            to_config("set nomenu_scroll\nset menu_context = 3\nset nomenu_move_off\nset nohelp\n");
3303        assert_eq!(cfg.ui.menu_scroll, Some(false), "{toml}");
3304        assert_eq!(cfg.ui.menu_context, 3);
3305        assert_eq!(cfg.ui.menu_move_off, Some(false));
3306        assert_eq!(cfg.ui.help, Some(false));
3307        // The ones rmut answers another way are reported, not holes.
3308        let (_, toml) = to_config(
3309            "set keep_flagged\nset thread_received\nset sleep_time = 0\nset read_inc = 100\n\
3310             set hide_limited\n",
3311        );
3312        assert!(!toml.contains("not imported"), "{toml}");
3313        assert!(toml.contains("no $move"), "{toml}");
3314    }
3315
3316    #[test]
3317    fn browser_alias_shell_tmpdir_carry_over() {
3318        let (cfg, toml) = to_config(
3319            "set sort_browser = reverse-date\nset sort_alias = alias\nset shell = /bin/zsh\n\
3320             set tmpdir = ~/tmp\n",
3321        );
3322        assert_eq!(
3323            cfg.ui.sort_browser.as_deref(),
3324            Some("reverse-date"),
3325            "{toml}"
3326        );
3327        assert_eq!(cfg.mail.sort_alias.as_deref(), Some("alias"));
3328        assert_eq!(cfg.mail.shell.as_deref(), Some("/bin/zsh"));
3329        assert_eq!(cfg.mail.tmpdir.as_deref(), Some("~/tmp"));
3330        let (cfg, _) = to_config("set ispell = \"aspell -c\"\n");
3331        assert_eq!(cfg.mail.ispell.as_deref(), Some("aspell -c"));
3332        let (_, toml) = to_config("set sort_browser = alpha\nset sort_alias = address\n");
3333        assert!(!toml.contains("not imported"), "{toml}");
3334    }
3335
3336    #[test]
3337    fn status_chars_carries_over() {
3338        let (cfg, toml) = to_config("set status_chars = \"-*%A\"\n");
3339        assert_eq!(cfg.ui.status_chars.as_deref(), Some("-*%A"), "{toml}");
3340    }
3341
3342    #[test]
3343    fn layout_settings_carry_over() {
3344        let (cfg, toml) = to_config("set status_on_top = yes\nset arrow_cursor = yes\n");
3345        assert_eq!(cfg.ui.status_on_top, Some(true), "{toml}");
3346        assert_eq!(cfg.ui.arrow_cursor, Some(true));
3347    }
3348
3349    #[test]
3350    fn history_file_carries_over() {
3351        let (cfg, toml) = to_config("set history_file = ~/.rmut_history\n");
3352        assert_eq!(
3353            cfg.ui.history_file.as_deref(),
3354            Some("~/.rmut_history"),
3355            "{toml}"
3356        );
3357    }
3358
3359    #[test]
3360    fn ts_title_settings_carry_over() {
3361        let (cfg, toml) = to_config(concat!(
3362            "set ts_enabled = yes\n",
3363            "set ts_status_format = \"rmut %f (%m)\"\n",
3364        ));
3365        assert_eq!(cfg.ui.set_title, Some(true), "{toml}");
3366        assert_eq!(cfg.ui.title_format.as_deref(), Some("rmut %f (%m)"));
3367    }
3368
3369    #[test]
3370    fn reply_crypto_settings_carry_over() {
3371        let (cfg, toml) = to_config(concat!(
3372            "set crypt_replysign = yes\n",
3373            "set crypt_replyencrypt = yes\n",
3374        ));
3375        assert!(cfg.pgp.reply_sign, "{toml}");
3376        assert!(cfg.pgp.reply_encrypt);
3377        assert!(!cfg.pgp.reply_sign_encrypted);
3378    }
3379
3380    #[test]
3381    fn postpone_and_recall_quadoptions_carry_over() {
3382        let (cfg, toml) = to_config(concat!("set postpone = no\n", "set recall = yes\n",));
3383        assert_eq!(cfg.mail.postpone.as_deref(), Some("no"), "{toml}");
3384        assert_eq!(cfg.mail.recall.as_deref(), Some("yes"));
3385        // The defaults (postpone ask-yes, recall ask) are satisfied,
3386        // not carried.
3387        let (cfg, _) = to_config(concat!("set postpone = ask-yes\n", "set recall = ask-no\n",));
3388        assert_eq!(cfg.mail.postpone, None);
3389        assert_eq!(cfg.mail.recall, None);
3390    }
3391
3392    #[test]
3393    fn the_envelope_settings_carry_over() {
3394        let (cfg, toml) = to_config(concat!(
3395            "set hostname = mail.example.net\n",
3396            "set user_agent = yes\n",
3397            "set sig_on_top = yes\n",
3398        ));
3399        assert_eq!(
3400            cfg.mail.hostname.as_deref(),
3401            Some("mail.example.net"),
3402            "{toml}"
3403        );
3404        assert_eq!(cfg.mail.user_agent, Some(true));
3405        assert_eq!(cfg.mail.sig_on_top, Some(true));
3406    }
3407
3408    #[test]
3409    fn the_trust_settings_carry_over() {
3410        let (cfg, toml) = to_config(concat!(
3411            "set certificate_file = ~/.mutt/certs.pem\n",
3412            "set ssl_usesystemcerts = no\n",
3413        ));
3414        assert_eq!(
3415            cfg.net.certificate_file.as_deref(),
3416            Some("~/.mutt/certs.pem"),
3417            "{toml}"
3418        );
3419        assert!(!cfg.net.system_cas, "{toml}");
3420        // ssl_ca_certificates_file is the same slot, and yes is
3421        // satisfied rather than carried.
3422        let (cfg, _) = to_config(concat!(
3423            "set ssl_ca_certificates_file = /etc/ssl/roots.pem\n",
3424            "set ssl_usesystemcerts = yes\n",
3425        ));
3426        assert_eq!(
3427            cfg.net.certificate_file.as_deref(),
3428            Some("/etc/ssl/roots.pem")
3429        );
3430        assert!(cfg.net.system_cas);
3431    }
3432
3433    #[test]
3434    fn imap_folder_becomes_an_account() {
3435        let (cfg, toml) = to_config(concat!(
3436            "set folder = imaps://mail.example.com\n",
3437            "set spoolfile = +INBOX\n",
3438            "set imap_user = jane\n",
3439            "set imap_pass = hunter2\n",
3440            "set smtp_url = smtps://jane@smtp.example.com:465\n",
3441            "set record = +Sent\n",
3442            "mailboxes +INBOX +Archive\n",
3443        ));
3444        let acct = cfg.account("mutt").unwrap();
3445        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3446        assert_eq!(acct.user, "jane");
3447        assert_eq!(acct.smtp_host, Some("smtp.example.com".into()));
3448        assert_eq!(acct.smtp_port, 465);
3449        assert_eq!(acct.sent_folder, "Sent");
3450        assert_eq!(
3451            cfg.mail.mailboxes,
3452            vec!["imap:mutt/INBOX", "imap:mutt/Archive"]
3453        );
3454        // imap_pass carries over as the stored password.
3455        assert_eq!(acct.password.as_deref(), Some("hunter2"));
3456        assert_eq!(acct.password().unwrap(), "hunter2");
3457        assert!(toml.contains("consider password_command"), "{toml}");
3458    }
3459
3460    #[test]
3461    fn plain_imap_url_means_starttls_port_never_disabled_tls() {
3462        let (cfg, toml) = to_config(concat!(
3463            "set folder = imap://mail.example.com\n",
3464            "set imap_user = u\n",
3465            "set smtp_url = smtps://smtp.example.com\n",
3466        ));
3467        let acct = cfg.account("mutt").unwrap();
3468        assert_eq!(acct.imap_port, 143);
3469        assert!(acct.imap_tls, "STARTTLS, not plaintext");
3470        assert!(!toml.contains("imap_tls"), "{toml}");
3471        assert_eq!(acct.smtp_port, 465);
3472    }
3473
3474    #[test]
3475    fn sort_pager_and_forward_directives_import() {
3476        let (cfg, toml) = to_config(concat!(
3477            "set sort                = \"threads\"\n",
3478            "set sort_aux            = last-date-sent\n",
3479            "set date_format         = \"%d.%m.%Y\"\n",
3480            "set pager_index_lines   = 10\n",
3481            "set pager_context       = 3\n",
3482            "set mime_forward        = yes\n",
3483            "set mime_forward_rest   = yes\n",
3484            "set query_command       = \"khard email --parsable %s\"\n",
3485            "set trash               = +Trash\n",
3486            "set edit_headers        = yes\n",
3487            "save-hook . +General\n",
3488            "set folder = ~/Mail\n",
3489            "bind index G imap-fetch-mail\n",
3490        ));
3491        assert_eq!(cfg.index.sort.as_deref(), Some("threads"));
3492        assert_eq!(cfg.index.sort_aux.as_deref(), Some("last-date-sent"));
3493        assert_eq!(cfg.index.date_format.as_deref(), Some("%d.%m.%Y"));
3494        assert_eq!(cfg.pager.index_lines, 10);
3495        assert_eq!(cfg.pager.context, 3);
3496        assert_eq!(cfg.mail.forward.as_deref(), Some("attach"));
3497        assert_eq!(
3498            cfg.mail.query_command.as_deref(),
3499            Some("khard email --parsable %s")
3500        );
3501        assert_eq!(cfg.mail.save.as_deref(), Some("~/Mail/General"));
3502        assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
3503        assert_eq!(cfg.mail.edit_headers, Some(true));
3504        // edit_headers = no matches rmut's default.
3505        let (cfg2, _) = to_config("set edit_headers = no\n");
3506        assert!(cfg2.mail.edit_headers.is_none());
3507        assert_eq!(
3508            cfg.keys.index.get("fetch-mail").map(String::as_str),
3509            Some("G")
3510        );
3511        assert!(toml.contains("mime_forward_rest"), "{toml}");
3512        assert!(!toml.contains("# not imported"), "{toml}");
3513    }
3514
3515    #[test]
3516    fn auto_view_and_peek_and_tagged_color() {
3517        let (cfg, toml) = to_config(concat!(
3518            "auto_view application/zip\n",
3519            "auto_view text/x-patch text/x-diff\n",
3520            "auto_view application/pgp-signature application/pgp\n",
3521            "auto_view text/html\n",
3522            "auto_view text/calendar\n",
3523            "unauto_view text/calendar\n",
3524            "alternative_order text/enriched text/plain TEXT/HTML\n",
3525            "unalternative_order text/enriched\n",
3526            "set imap_peek           = yes\n",
3527            "set menu_scroll\n",
3528            "bind index % noop\n",
3529            "color index black   cyan    \"~T\"\n",
3530        ));
3531        // Every auto_view type becomes a [filters] entry; the command
3532        // is left empty, which is rmut's "take it from mailcap", the
3533        // very place mutt takes it from.
3534        for mime in [
3535            "application/zip",
3536            "text/x-patch",
3537            "text/x-diff",
3538            "text/html",
3539        ] {
3540            assert_eq!(
3541                cfg.filters.get(mime).map(String::as_str),
3542                Some(""),
3543                "{mime}"
3544            );
3545        }
3546        // unauto_view takes one back off.
3547        assert!(!cfg.filters.contains_key("text/calendar"), "{toml}");
3548        assert_eq!(cfg.pager.alternative_order, ["text/plain", "text/html"]);
3549        // black-on-cyan keeps both colours, as a rule: the slot holds
3550        // one, and mutt painted a bar.
3551        let tagged = cfg
3552            .color_index
3553            .iter()
3554            .find(|r| r.pattern == "~T")
3555            .expect("a ~T rule");
3556        assert_eq!(tagged.fg.as_deref(), Some("black"));
3557        assert_eq!(tagged.bg.as_deref(), Some("cyan"));
3558        assert!(!cfg.colors.contains_key("tagged"));
3559        assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3560        for satisfied in ["pgp-signature", "imap_peek", "menu_scroll", "noop"] {
3561            assert!(toml.contains(satisfied), "{satisfied} missing:\n{toml}");
3562        }
3563        assert!(toml.contains("application/zip"), "{toml}");
3564    }
3565
3566    #[test]
3567    fn imap_pass_without_imap_folder_is_redacted() {
3568        let (cfg, toml) = to_config("set folder = ~/Mail\nset imap_pass = hunter2\n");
3569        assert!(cfg.accounts.is_empty());
3570        assert!(!toml.contains("hunter2"), "password must not leak:\n{toml}");
3571        assert!(toml.contains("(redacted)"), "{toml}");
3572    }
3573
3574    #[test]
3575    fn url_style_spoolfile_record_and_mailboxes() {
3576        // The common mutt style: full URLs everywhere, no +shortcuts.
3577        let (cfg, toml) = to_config(concat!(
3578            "set folder = \"imaps://mail.example.com/\"\n",
3579            "set spoolfile = \"imaps://mail.example.com/INBOX\"\n",
3580            "set record = \"imaps://mail.example.com/Sent\"\n",
3581            "set imap_user = jane\n",
3582            "mailboxes imaps://mail.example.com/INBOX imaps://mail.example.com/Archive\n",
3583        ));
3584        let acct = cfg.account("mutt").unwrap();
3585        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3586        assert_eq!(acct.sent_folder, "Sent");
3587        assert_eq!(
3588            cfg.mail.mailboxes,
3589            vec!["imap:mutt/INBOX", "imap:mutt/Archive"]
3590        );
3591        assert!(!toml.contains("//INBOX"), "no doubled separators:\n{toml}");
3592    }
3593
3594    #[test]
3595    fn url_with_userinfo_and_empty_port() {
3596        // Seen in the wild: user@ in the URL and a dangling colon.
3597        let (cfg, toml) = to_config(concat!(
3598            "set folder = \"imap://jane@mail.example.com:/\"\n",
3599            "set spoolfile = \"imap://jane@mail.example.com:/INBOX\"\n",
3600        ));
3601        let acct = cfg.account("mutt").unwrap();
3602        assert_eq!(acct.user, "jane");
3603        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3604        assert_eq!(acct.imap_port, 143);
3605        assert!(acct.imap_tls, "imap:// means STARTTLS, not plaintext");
3606        assert_eq!(cfg.mail.mailboxes, vec!["imap:mutt/INBOX"]);
3607        assert!(
3608            !toml.contains("jane@mail.example.com"),
3609            "no URLs in specs:\n{toml}"
3610        );
3611    }
3612
3613    #[test]
3614    fn url_spoolfile_alone_identifies_the_server() {
3615        let (cfg, _) = to_config(concat!(
3616            "set spoolfile = imaps://mail.example.com:1993/INBOX\n",
3617            "set imap_user = jane\n",
3618        ));
3619        let acct = cfg.account("mutt").unwrap();
3620        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3621        assert_eq!(acct.imap_port, 1993);
3622        assert_eq!(cfg.mail.mailboxes, vec!["imap:mutt/INBOX"]);
3623    }
3624
3625    #[test]
3626    fn smtp_only_muttrc_still_gets_an_account() {
3627        let (cfg, _) = to_config(concat!(
3628            "set folder = ~/Mail\n",
3629            "set from = jane@x\n",
3630            "set smtp_url = smtp://smtp.example.com:587\n",
3631            "set smtp_pass = sekrit\n",
3632        ));
3633        let acct = cfg.account("mutt").unwrap();
3634        assert!(acct.imap_host.is_none());
3635        assert_eq!(acct.smtp_host, Some("smtp.example.com".into()));
3636        assert_eq!(acct.user, "jane@x");
3637        assert_eq!(acct.password.as_deref(), Some("sekrit"));
3638    }
3639
3640    #[test]
3641    fn differing_smtp_pass_is_noted_and_redacted() {
3642        let (cfg, toml) = to_config(concat!(
3643            "set folder = imaps://h\n",
3644            "set imap_user = u\n",
3645            "set imap_pass = aaa\n",
3646            "set smtp_url = smtp://s\n",
3647            "set smtp_pass = bbb\n",
3648        ));
3649        assert_eq!(
3650            cfg.account("mutt").unwrap().password.as_deref(),
3651            Some("aaa")
3652        );
3653        assert!(!toml.contains("bbb"), "smtp_pass must not leak:\n{toml}");
3654        assert!(toml.contains("differs from imap_pass"), "{toml}");
3655    }
3656
3657    #[test]
3658    fn default_matching_directives_are_acknowledged() {
3659        let (cfg, toml) = to_config(concat!(
3660            "set ssl_starttls        = yes\n",
3661            "set ssl_force_tls       = yes\n",
3662            "set charset             = \"UTF-8\"\n",
3663            "set pgp_auto_decode     = yes\n",
3664            "set smtp_authenticators =\"login\"\n",
3665            "set print_command       = \"a2ps\"\n",
3666        ));
3667        assert_eq!(cfg.mail.print.as_deref(), Some("a2ps"));
3668        assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3669        for directive in [
3670            "ssl_starttls",
3671            "ssl_force_tls",
3672            "charset",
3673            "pgp_auto_decode",
3674            "smtp_authenticators",
3675        ] {
3676            assert!(toml.contains(directive), "{directive} missing:\n{toml}");
3677        }
3678        assert!(!toml.contains("# not imported"), "{toml}");
3679    }
3680
3681    #[test]
3682    fn non_default_tls_and_charset_still_surface() {
3683        let (_cfg, toml) = to_config(concat!(
3684            "set ssl_force_tls = no\n",
3685            "set charset = \"iso-8859-2\"\n",
3686            "set smtp_authenticators = \"oauthbearer\"\n",
3687        ));
3688        assert!(toml.contains("# not imported:"), "{toml}");
3689        assert!(!toml.contains("# satisfied"), "{toml}");
3690    }
3691
3692    #[test]
3693    fn account_without_imap_pass_gets_a_placeholder() {
3694        let (cfg, toml) = to_config("set folder = imaps://h.example.com\nset imap_user = u\n");
3695        assert!(cfg.account("mutt").unwrap().password_command.is_some());
3696        assert!(toml.contains("pass show mail/TODO"), "{toml}");
3697    }
3698
3699    #[test]
3700    fn aliases_and_unknowns_surface_as_comments() {
3701        let import = import(
3702            "alias petr Petr Novak <petr@example.com>\nset sleep_time = 0\nmacro index x \"<shell-escape>ls\\n\"\n",
3703            Path::new("/"),
3704        );
3705        // Aliases come back on their own: rmut keeps them in a
3706        // mutt-format alias file, so the caller either writes that
3707        // file or shows them with alias_block.
3708        assert_eq!(import.aliases, ["alias petr Petr Novak <petr@example.com>"]);
3709        assert!(!import.toml.contains("alias petr"), "{}", import.toml);
3710        let block = alias_block(&import.aliases, Path::new("/home/x/.config/rmut/aliases"));
3711        assert!(
3712            block.contains("#   alias petr Petr Novak <petr@example.com>"),
3713            "{block}"
3714        );
3715        assert!(block.contains("/home/x/.config/rmut/aliases"), "{block}");
3716        assert!(alias_block(&[], Path::new("/x")).is_empty());
3717        assert!(import.toml.contains("set sleep_time = 0"));
3718        assert!(import.toml.contains("macro index x"), "{}", import.toml);
3719        assert!(import.toml.contains("mutt function names"));
3720        // and the output is still valid (if empty) config
3721        toml::from_str::<Config>(&import.toml).unwrap();
3722    }
3723
3724    #[test]
3725    fn source_includes_are_followed() {
3726        let tmp = tempfile::tempdir().unwrap();
3727        std::fs::write(tmp.path().join("extra"), "set realname = Included\n").unwrap();
3728        let import = import("source extra\nsource ./missing\n", tmp.path());
3729        assert!(import.toml.contains("name = \"Included\""));
3730        assert!(import.toml.contains("source ./missing"));
3731    }
3732
3733    #[test]
3734    fn continuations_and_quoting() {
3735        let lines = logical_lines("set realname = \\\n  \"Jane # not a comment\"\n# gone\n");
3736        assert_eq!(lines, vec!["set realname =   \"Jane # not a comment\""]);
3737        assert_eq!(
3738            tokenize("bind index \\Cd delete-message"),
3739            vec!["bind", "index", "\\Cd", "delete-message"]
3740        );
3741        assert_eq!(tokenize("set from='a b' c"), vec!["set", "from=a b", "c"]);
3742    }
3743
3744    #[test]
3745    fn convert_key_forms() {
3746        assert_eq!(convert_key("\\Cx").as_deref(), Some("ctrl+x"));
3747        assert_eq!(convert_key("\\CX").as_deref(), Some("ctrl+x"));
3748        assert_eq!(convert_key("\\ev").as_deref(), Some("alt+v"));
3749        assert_eq!(convert_key("<esc>V").as_deref(), Some("alt+V"));
3750        assert_eq!(convert_key("<Enter>").as_deref(), Some("enter"));
3751        assert_eq!(convert_key("<PageDown>").as_deref(), Some("pgdn"));
3752        assert_eq!(convert_key("G").as_deref(), Some("G"));
3753        assert_eq!(convert_key("gg"), None);
3754        assert_eq!(convert_key("<f5>"), None);
3755    }
3756}