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