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