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        "<up>" => "up",
2510        "<down>" => "down",
2511        "<pageup>" => "pgup",
2512        "<pagedown>" => "pgdn",
2513        "<home>" => "home",
2514        "<end>" => "end",
2515        _ => {
2516            let mut chars = key.chars();
2517            let c = chars.next()?;
2518            return (chars.next().is_none() && c != '<').then(|| c.to_string());
2519        }
2520    };
2521    Some(named.to_string())
2522}
2523
2524/// A mutt macro sequence in rmut's syntax: literal characters,
2525/// `<key-name>`s rmut knows, and `\`-escapes (`\n` `\t` `\e` `\Cx`).
2526/// None when it names mutt functions or exotic keys.
2527fn convert_sequence(seq: &str) -> Option<String> {
2528    const NAMES: [&str; 15] = [
2529        "enter",
2530        "return",
2531        "esc",
2532        "escape",
2533        "space",
2534        "tab",
2535        "backspace",
2536        "up",
2537        "down",
2538        "pgup",
2539        "pageup",
2540        "pgdn",
2541        "pagedown",
2542        "home",
2543        "end",
2544    ];
2545    let mut out = String::new();
2546    let mut chars = seq.chars();
2547    while let Some(c) = chars.next() {
2548        match c {
2549            '<' => {
2550                let mut name = String::new();
2551                loop {
2552                    match chars.next() {
2553                        Some('>') => break,
2554                        Some(c) => name.push(c),
2555                        None => return None,
2556                    }
2557                }
2558                let name = name.to_lowercase();
2559                if !NAMES.contains(&name.as_str()) {
2560                    return None; // a mutt function name
2561                }
2562                out += &format!("<{name}>");
2563            }
2564            '\\' => match chars.next()? {
2565                'n' | 'r' => out += "<enter>",
2566                't' => out += "<tab>",
2567                'e' => out += "<esc>",
2568                'c' | 'C' => out += &format!("<ctrl+{}>", chars.next()?.to_ascii_lowercase()),
2569                other => out.push(other),
2570            },
2571            c => out.push(c),
2572        }
2573    }
2574    Some(out)
2575}
2576
2577/// mutt "bright" colors are close to ratatui's "light" family.
2578pub(crate) fn convert_color(name: &str) -> String {
2579    match name.strip_prefix("bright") {
2580        Some(base) => format!("light{base}"),
2581        None => name.to_string(),
2582    }
2583}
2584
2585pub fn index_function(name: &str) -> Option<&'static str> {
2586    Some(match name {
2587        "quit" => "quit",
2588        "exit" => "abort",
2589        "next-entry" | "next-undeleted" => "down",
2590        "previous-entry" | "previous-undeleted" => "up",
2591        "next-page" => "page-down",
2592        "previous-page" => "page-up",
2593        "first-entry" => "first",
2594        "last-entry" => "last",
2595        "display-message" => "view",
2596        "delete-message" => "delete",
2597        "undelete-message" => "undelete",
2598        "flag-message" => "flag",
2599        "toggle-new" => "toggle-new",
2600        "sync-mailbox" => "sync",
2601        "mail" => "compose",
2602        "reply" => "reply",
2603        "group-reply" => "group-reply",
2604        "forward-message" => "forward",
2605        "sort-mailbox" => "sort",
2606        "limit" => "limit",
2607        "search" => "search",
2608        "search-next" => "search-next",
2609        "next-new" | "next-new-then-unread" | "next-unread" => "next-new",
2610        "previous-new" | "previous-new-then-unread" | "previous-unread" => "previous-new",
2611        "delete-pattern" => "delete-pattern",
2612        "undelete-pattern" => "undelete-pattern",
2613        "tag-pattern" => "tag-pattern",
2614        "untag-pattern" => "untag-pattern",
2615        "change-folder" => "change-mailbox",
2616        "view-attachments" => "attachments",
2617        "collapse-thread" => "fold-thread",
2618        "collapse-all" => "fold-all",
2619        // The thread functions, under mutt's own names.
2620        "delete-thread" => "delete-thread",
2621        "undelete-thread" => "undelete-thread",
2622        "tag-thread" => "tag-thread",
2623        "delete-subthread" => "delete-subthread",
2624        "undelete-subthread" => "undelete-subthread",
2625        "next-thread" => "next-thread",
2626        "previous-thread" => "previous-thread",
2627        "break-thread" => "break-thread",
2628        "link-threads" => "link-threads",
2629        "read-thread" => "read-thread",
2630        "read-subthread" => "read-subthread",
2631        "tag-subthread" => "tag-subthread",
2632        "parent-message" => "parent-message",
2633        "root-message" => "root-message",
2634        "imap-fetch-mail" | "fetch-mail" => "fetch-mail",
2635        "tag-entry" | "tag-message" => "tag",
2636        "tag-prefix" => "tag-prefix",
2637        "query" => "query",
2638        "save-message" => "save",
2639        "decode-save" => "decode-save",
2640        "decode-copy" => "decode-copy",
2641        "print-message" => "print",
2642        "edit" => "edit",
2643        "resend-message" => "resend",
2644        "edit-label" => "edit-label",
2645        "show-version" => "show-version",
2646        "show-limit" => "show-limit",
2647        "display-address" => "display-address",
2648        "toggle-write" => "toggle-write",
2649        "top-page" => "top-page",
2650        "middle-page" => "middle-page",
2651        "bottom-page" => "bottom-page",
2652        "help" => "help",
2653        _ => return None,
2654    })
2655}
2656
2657pub fn pager_function(name: &str) -> Option<&'static str> {
2658    Some(match name {
2659        "exit" => "back",
2660        "next-line" => "down",
2661        "previous-line" => "up",
2662        "next-page" => "page-down",
2663        "previous-page" => "page-up",
2664        "half-down" => "half-down",
2665        "half-up" => "half-up",
2666        "top" => "top",
2667        "bottom" => "bottom",
2668        "toggle-quoted" => "toggle-quoted",
2669        "skip-quoted" => "skip-quoted",
2670        "next-entry" => "next",
2671        "previous-entry" => "previous",
2672        "next-undeleted" => "next-undeleted",
2673        "previous-undeleted" => "previous-undeleted",
2674        "delete-message" => "delete",
2675        "display-toggle-weed" => "headers",
2676        "search" => "search",
2677        "search-next" => "search-next",
2678        "search-opposite" => "search-prev",
2679        "search-toggle" => "search-toggle",
2680        "view-attachments" => "attachments",
2681        "mail" => "compose",
2682        "reply" => "reply",
2683        "group-reply" => "group-reply",
2684        "forward-message" => "forward",
2685        "save-message" => "save",
2686        "print-message" => "print",
2687        "edit" => "edit",
2688        "resend-message" => "resend",
2689        "help" => "help",
2690        _ => return None,
2691    })
2692}
2693
2694#[cfg(test)]
2695mod tests {
2696    use super::*;
2697    use crate::config::Config;
2698
2699    fn to_config(muttrc: &str) -> (Config, String) {
2700        let import = import(muttrc, Path::new("/nonexistent"));
2701        let cfg: Config = toml::from_str(&import.toml)
2702            .unwrap_or_else(|e| panic!("bad TOML: {e}\n{}", import.toml));
2703        (cfg, import.toml)
2704    }
2705
2706    #[test]
2707    fn format_flowed_options_import() {
2708        let (cfg, toml) = to_config("set text_flowed\nset noreflow_text\n");
2709        assert!(cfg.mail.text_flowed);
2710        assert_eq!(cfg.pager.reflow_text, Some(false));
2711        assert!(toml.contains("text_flowed = true"), "{toml}");
2712        assert!(toml.contains("reflow_text = false"), "{toml}");
2713        // Both defaults match rmut's, so an explicit default is quiet.
2714        let (cfg, _) = to_config("set notext_flowed\nset reflow_text\n");
2715        assert!(!cfg.mail.text_flowed);
2716        assert_eq!(cfg.pager.reflow_text, None);
2717    }
2718
2719    #[test]
2720    fn index_colors_keep_a_background() {
2721        let (cfg, toml) = to_config(concat!(
2722            "color index black magenta \"~D\"\n",
2723            "color index brightred default \"~F\"\n",
2724        ));
2725        // A background means mutt painted a bar: both colours stay,
2726        // as a [[color_index]] rule.
2727        let deleted = cfg.color_index.iter().find(|r| r.pattern == "~D").unwrap();
2728        assert_eq!(deleted.fg.as_deref(), Some("black"));
2729        assert_eq!(deleted.bg.as_deref(), Some("magenta"));
2730        // No background: one visible colour, which is what the
2731        // built-in slot holds.
2732        assert_eq!(
2733            cfg.colors.get("flagged").map(String::as_str),
2734            Some("lightred")
2735        );
2736        assert!(toml.contains("[[color_index]]"), "{toml}");
2737    }
2738
2739    #[test]
2740    fn attachment_reminder_imports() {
2741        let (cfg, toml) = to_config(concat!(
2742            "set abort_noattach = ask-yes\n",
2743            "set abort_noattach_regex = \"\\\\<(attach|pripojen)\"\n",
2744        ));
2745        // ask-yes and ask-no are the same question to rmut.
2746        assert_eq!(cfg.mail.abort_noattach.as_deref(), Some("ask"));
2747        assert_eq!(
2748            cfg.mail.attach_keyword.as_deref(),
2749            Some("\\b(attach|pripojen)")
2750        );
2751        assert!(toml.contains("abort_noattach"), "{toml}");
2752        let (cfg, _) = to_config("set abort_noattach = yes\n");
2753        assert_eq!(cfg.mail.abort_noattach.as_deref(), Some("yes"));
2754    }
2755
2756    #[test]
2757    fn hooks_round_two_translate() {
2758        let (cfg, toml) = to_config(concat!(
2759            "folder-hook work 'set index_format=\"%s\"'\n",
2760            "folder-hook . 'set sort=threads'\n",
2761            "message-hook '~f boss@example\\.com' 'set pager_context=5'\n",
2762            "reply-hook '~t @work\\.example\\.com' 'set from=jane@work.example.com'\n",
2763            "fcc-hook '~t @work\\.example\\.com' +work-sent\n",
2764            "fcc-save-hook boss@example.com +boss\n",
2765            "crypt-hook boss@example.com 0xDEADBEEF\n",
2766            "message-hook '~X 3' 'set beep'\n", // unparseable pattern
2767            "reply-hook '~s x' 'frobnicate'\n", // unknown command
2768        ));
2769        assert_eq!(cfg.folder_hooks.len(), 2);
2770        assert_eq!(cfg.folder_hooks[0].folder, "*work*");
2771        assert_eq!(cfg.folder_hooks[0].command, "set index_format=\"%s\"");
2772        assert_eq!(cfg.folder_hooks[1].folder, "*");
2773        assert_eq!(cfg.message_hooks.len(), 1);
2774        assert_eq!(cfg.message_hooks[0].command, "set pager_context=5");
2775        assert_eq!(cfg.reply_hooks.len(), 1);
2776        assert_eq!(cfg.reply_hooks[0].pattern, "~t @work\\.example\\.com");
2777        assert_eq!(cfg.fcc_hooks.len(), 2);
2778        assert_eq!(cfg.fcc_hooks[0].mailbox, "work-sent");
2779        // A bare fcc-hook pattern gets mutt's $default_hook expansion.
2780        assert_eq!(
2781            cfg.fcc_hooks[1].pattern,
2782            "(~f \"boss@example.com\" !~P) | (~P ~C \"boss@example.com\")"
2783        );
2784        assert_eq!(cfg.crypt_hooks.len(), 1);
2785        assert_eq!(cfg.crypt_hooks[0].key, "0xDEADBEEF");
2786        // The two broken hooks stay visible as comments.
2787        assert!(toml.contains("pattern does not translate"), "{toml}");
2788        assert!(toml.contains("frobnicate"), "{toml}");
2789    }
2790
2791    #[test]
2792    fn default_hook_expansion() {
2793        assert_eq!(default_hook_pattern("."), "~A");
2794        assert_eq!(default_hook_pattern("~t x"), "~t x");
2795        assert_eq!(default_hook_pattern("!~P"), "!~P");
2796        assert_eq!(
2797            default_hook_pattern("a@b"),
2798            "(~f \"a@b\" !~P) | (~P ~C \"a@b\")"
2799        );
2800    }
2801
2802    #[test]
2803    fn alternates_and_my_hdr_import() {
2804        let (cfg, toml) = to_config(concat!(
2805            "alternates jane@old\\.example\\.com '@club\\.example\\.com$'\n",
2806            "alternates typo@example\\.com\n",
2807            "unalternates typo@example\\.com\n",
2808            "my_hdr Organization: Acme\n",
2809            "my_hdr X-Mailer: rmut\n",
2810            "my_hdr Organization: Acme Ltd\n",
2811            "unmy_hdr X-Mailer\n",
2812            "set metoo\n",
2813        ));
2814        assert_eq!(
2815            cfg.mail.alternates,
2816            ["jane@old\\.example\\.com", "@club\\.example\\.com$"]
2817        );
2818        // One entry per header name: the later Organization wins, and
2819        // unmy_hdr takes X-Mailer back out.
2820        assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
2821        assert!(cfg.mail.metoo);
2822        assert!(toml.contains("alternates = ["), "{toml}");
2823        assert!(toml.contains("metoo = true"), "{toml}");
2824    }
2825
2826    #[test]
2827    fn hooks_become_identity_rules() {
2828        let (cfg, toml) = to_config(concat!(
2829            "set reverse_name = yes\n",
2830            "folder-hook work 'set from=\"Jane Work <jane@work.example.com>\"'\n",
2831            "send-hook '~t @club\\.example\\.com' 'set realname=\"Jenny\"'\n",
2832            "folder-hook . 'push <collapse-all>'\n",
2833            "send-hook '~l' 'set from=list@example.com'\n",
2834        ));
2835        assert!(cfg.identity.reverse_name);
2836        assert_eq!(cfg.identities.len(), 2);
2837        let work = &cfg.identities[0];
2838        assert_eq!(work.folder.as_deref(), Some("*work*"));
2839        assert!(work.recipient.is_none());
2840        assert_eq!(work.name.as_deref(), Some("Jane Work"));
2841        assert_eq!(work.email.as_deref(), Some("jane@work.example.com"));
2842        let club = &cfg.identities[1];
2843        assert_eq!(club.recipient.as_deref(), Some("*@club.example.com*"));
2844        assert!(club.folder.is_none());
2845        assert_eq!(club.name.as_deref(), Some("Jenny"));
2846        // A folder-hook that is not an identity set becomes a
2847        // [[folder_hooks]] line; the ~l send-hook stays a comment.
2848        assert_eq!(cfg.folder_hooks.len(), 1);
2849        assert_eq!(cfg.folder_hooks[0].folder, "*");
2850        assert_eq!(cfg.folder_hooks[0].command, "push <collapse-all>");
2851        assert!(toml.contains("does not translate to a glob"), "{toml}");
2852        // reverse_name off matches rmut's default.
2853        let (cfg, toml) = to_config("set reverse_name = no\n");
2854        assert!(!cfg.identity.reverse_name);
2855        assert!(toml.contains("# satisfied"), "{toml}");
2856    }
2857
2858    #[test]
2859    fn oauth_authenticators_become_account_auth() {
2860        let (cfg, toml) = to_config(concat!(
2861            "set folder = \"imaps://outlook.example.com/\"\n",
2862            "set imap_user = \"jane@example.com\"\n",
2863            "set imap_pass = \"hunter2\"\n",
2864            "set imap_authenticators = \"oauthbearer\"\n",
2865            "set smtp_authenticators = \"xoauth2:plain\"\n",
2866        ));
2867        let account = cfg.account("mutt").unwrap();
2868        // The last-seen mechanism wins; both directives name OAuth.
2869        assert_eq!(account.auth.as_deref(), Some("xoauth2"));
2870        assert_eq!(account.token_command.as_deref(), Some("oauth2ms"));
2871        // The stored password is not emitted alongside OAuth.
2872        assert!(account.password.is_none());
2873        assert!(!toml.contains("hunter2"), "{toml}");
2874        assert!(toml.contains("the account authenticates with OAuth"));
2875        assert!(toml.contains("fresh access token"), "{toml}");
2876        // Plain-only stays satisfied, unknown mechanisms stay skipped.
2877        let (_, toml) = to_config("set smtp_authenticators = \"plain\"\n");
2878        assert!(toml.contains("# satisfied"), "{toml}");
2879        let (_, toml) = to_config("set smtp_authenticators = \"gssapi\"\n");
2880        assert!(toml.contains("xoauth2/oauthbearer"), "{toml}");
2881    }
2882
2883    #[test]
2884    fn color_index_patterns_and_status_format_import() {
2885        let (cfg, toml) = to_config(concat!(
2886            "color index yellow default \"~f boss@example.com\"\n",
2887            "color index brightred blue \"~d <1w ~U\"\n",
2888            "color index red default ~D\n",         // still a slot
2889            "color index green default \"~X 3\"\n", // unparseable
2890            "set status_format = \"-%r- %f [%m msgs%?t?, %t tagged?]\"\n",
2891        ));
2892        assert_eq!(cfg.color_index.len(), 2);
2893        assert_eq!(cfg.color_index[0].pattern, "~f boss@example.com");
2894        assert_eq!(cfg.color_index[0].fg.as_deref(), Some("yellow"));
2895        assert!(cfg.color_index[0].bg.is_none()); // "default" dropped
2896        assert_eq!(cfg.color_index[1].fg.as_deref(), Some("lightred"));
2897        assert_eq!(cfg.color_index[1].bg.as_deref(), Some("blue"));
2898        assert_eq!(cfg.colors.get("deleted").map(String::as_str), Some("red"));
2899        assert_eq!(
2900            cfg.ui.status_format.as_deref(),
2901            Some("-%r- %f [%m msgs%?t?, %t tagged?]")
2902        );
2903        assert!(toml.contains("no rmut color slot"), "{toml}");
2904    }
2905
2906    #[test]
2907    fn macros_translate_plain_sequences() {
2908        let (cfg, toml) = to_config(concat!(
2909            "macro index L \"l~f jane\\n\" \"limit to jane\"\n",
2910            "macro index,pager \\Cs \"~s urgent<Enter>\"\n",
2911            "macro index A \"<collapse-all>\"\n",
2912            "macro compose X \"y\"\n",
2913        ));
2914        assert_eq!(
2915            cfg.macros.index.get("L").map(String::as_str),
2916            Some("l~f jane<enter>")
2917        );
2918        assert_eq!(
2919            cfg.macros.index.get("ctrl+s").map(String::as_str),
2920            Some("~s urgent<enter>")
2921        );
2922        assert_eq!(
2923            cfg.macros.pager.get("ctrl+s").map(String::as_str),
2924            Some("~s urgent<enter>")
2925        );
2926        // Function names and foreign menus stay visible as comments.
2927        assert!(toml.contains("mutt function names do not"), "{toml}");
2928        assert!(toml.contains("only index and pager menus"), "{toml}");
2929    }
2930
2931    #[test]
2932    fn hook_glob_shapes() {
2933        assert_eq!(hook_glob(".").as_deref(), Some("*"));
2934        assert_eq!(hook_glob("work").as_deref(), Some("*work*"));
2935        assert_eq!(hook_glob("^/mail/work$").as_deref(), Some("/mail/work"));
2936        assert_eq!(hook_glob("=lists").as_deref(), Some("*lists*"));
2937        assert_eq!(
2938            hook_glob("~t bob@example\\.com").as_deref(),
2939            Some("*bob@example.com*")
2940        );
2941        assert_eq!(hook_glob("work.*").as_deref(), Some("*work*"));
2942        assert!(hook_glob("(a|b)").is_none());
2943        assert!(hook_glob("~f jane").is_none());
2944    }
2945
2946    #[test]
2947    fn set_forms_and_identity() {
2948        let (cfg, _) = to_config(concat!(
2949            "set realname = \"Jane Doe\"\n",
2950            "set from=jane@example.com\n",
2951            "set editor = vim  # trailing comment\n",
2952            "set mail_check=30\n",
2953        ));
2954        assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
2955        assert_eq!(cfg.identity.email.as_deref(), Some("jane@example.com"));
2956        assert_eq!(cfg.mail.editor.as_deref(), Some("vim"));
2957        assert_eq!(cfg.mail.poll_seconds, Some(30));
2958    }
2959
2960    #[test]
2961    fn from_with_display_name_fills_both() {
2962        let (cfg, _) = to_config("set from = \"Jane Doe <jane@x.org>\"\n");
2963        assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
2964        assert_eq!(cfg.identity.email.as_deref(), Some("jane@x.org"));
2965        // explicit realname wins over the From display name
2966        let (cfg, _) = to_config("set realname=RN\nset from = \"DN <j@x>\"\n");
2967        assert_eq!(cfg.identity.name.as_deref(), Some("RN"));
2968    }
2969
2970    #[test]
2971    fn mailboxes_expand_against_folder() {
2972        let (cfg, _) = to_config(concat!(
2973            "set folder = ~/Mail\n",
2974            "set spoolfile = +inbox\n",
2975            "set record = +sent\n",
2976            "set postponed = +drafts\n",
2977            "mailboxes +inbox +work ~/other\n",
2978        ));
2979        assert_eq!(
2980            cfg.mail.mailboxes,
2981            vec!["~/Mail/inbox", "~/Mail/work", "~/other"]
2982        );
2983        assert_eq!(cfg.mail.sent.as_deref(), Some("~/Mail/sent"));
2984        assert_eq!(cfg.mail.postponed.as_deref(), Some("~/Mail/drafts"));
2985        // $folder carries over, so rmut expands +x / =x at runtime as
2986        // well: an imported macro can still say "=archive".
2987        assert_eq!(cfg.mail.folder.as_deref(), Some("~/Mail"));
2988    }
2989
2990    #[test]
2991    fn binds_translate_keys_and_functions() {
2992        let (cfg, toml) = to_config(concat!(
2993            "bind index \\Cd delete-message\n",
2994            "bind index <esc>s sync-mailbox\n",
2995            "bind pager <space> next-page\n",
2996            "bind index,pager R group-reply\n",
2997            "bind index gg first-entry\n", // multi-key: skipped
2998            "bind index Z frobnicate\n",   // unknown function: skipped
2999        ));
3000        assert_eq!(
3001            cfg.keys.index.get("delete").map(String::as_str),
3002            Some("ctrl+d")
3003        );
3004        assert_eq!(
3005            cfg.keys.index.get("sync").map(String::as_str),
3006            Some("alt+s")
3007        );
3008        assert_eq!(
3009            cfg.keys.pager.get("page-down").map(String::as_str),
3010            Some("space")
3011        );
3012        assert_eq!(
3013            cfg.keys.index.get("group-reply").map(String::as_str),
3014            Some("R")
3015        );
3016        assert_eq!(
3017            cfg.keys.pager.get("group-reply").map(String::as_str),
3018            Some("R")
3019        );
3020        assert!(toml.contains("# not imported:"));
3021        assert!(toml.contains("bind index gg first-entry"));
3022        assert!(toml.contains("bind index Z frobnicate"));
3023    }
3024
3025    #[test]
3026    fn colors_map_to_rmut_slots() {
3027        let (cfg, toml) = to_config(concat!(
3028            "color status brightyellow blue\n",
3029            "color header cyan default\n",
3030            "color index red default ~D\n",
3031            "color index brightmagenta default ~F\n",
3032            "color indicator black white\n", // no slot: skipped
3033        ));
3034        assert_eq!(
3035            cfg.colors.get("status_fg").map(String::as_str),
3036            Some("lightyellow")
3037        );
3038        assert_eq!(
3039            cfg.colors.get("status_bg").map(String::as_str),
3040            Some("blue")
3041        );
3042        assert_eq!(cfg.colors.get("header").map(String::as_str), Some("cyan"));
3043        assert_eq!(cfg.colors.get("deleted").map(String::as_str), Some("red"));
3044        assert_eq!(
3045            cfg.colors.get("flagged").map(String::as_str),
3046            Some("lightmagenta")
3047        );
3048        assert!(toml.contains("color indicator"));
3049    }
3050
3051    #[test]
3052    fn pager_colors_and_motion_translate() {
3053        let (cfg, toml) = to_config(concat!(
3054            "color quoted cyan default\n",
3055            "color quoted1 yellow default\n",
3056            "color body magenta default \"https?://[^ ]+\"\n",
3057            "color search black yellow\n",
3058            "set quote_regexp=\"^( *[>|])+\"\n",
3059            "bind pager \\Cd half-down\n",
3060            "bind pager T toggle-quoted\n",
3061            "bind pager S skip-quoted\n",
3062        ));
3063        assert_eq!(cfg.colors.get("quoted").map(String::as_str), Some("cyan"));
3064        assert_eq!(
3065            cfg.colors.get("quoted1").map(String::as_str),
3066            Some("yellow")
3067        );
3068        assert_eq!(
3069            cfg.colors.get("search_bg").map(String::as_str),
3070            Some("yellow")
3071        );
3072        assert_eq!(cfg.color_body.len(), 1);
3073        assert_eq!(cfg.color_body[0].pattern, "https?://[^ ]+");
3074        assert_eq!(cfg.color_body[0].fg.as_deref(), Some("magenta"));
3075        assert_eq!(cfg.pager.quote_regexp.as_deref(), Some("^( *[>|])+"));
3076        assert_eq!(
3077            cfg.keys.pager.get("half-down").map(String::as_str),
3078            Some("ctrl+d")
3079        );
3080        assert_eq!(
3081            cfg.keys.pager.get("toggle-quoted").map(String::as_str),
3082            Some("T")
3083        );
3084        assert!(toml.contains("[[color_body]]"));
3085    }
3086
3087    #[test]
3088    fn header_weeding_and_pager_polish_translate() {
3089        let (cfg, toml) = to_config(concat!(
3090            "ignore *\n",
3091            "unignore from date subject\n",
3092            "hdr_order Date: From: Subject:\n",
3093            "set pager_format=\"-%Z- %C/%m: %s\"\n",
3094            "set wrap = 78\n",
3095            "set tilde\n",
3096        ));
3097        assert_eq!(cfg.pager.ignore.as_deref(), Some(&["*".to_string()][..]));
3098        assert_eq!(
3099            cfg.pager.unignore.as_deref(),
3100            Some(&["from".to_string(), "date".into(), "subject".into()][..])
3101        );
3102        assert_eq!(
3103            cfg.pager.hdr_order.as_deref(),
3104            Some(&["Date".to_string(), "From".into(), "Subject".into()][..])
3105        );
3106        assert_eq!(cfg.pager.format.as_deref(), Some("-%Z- %C/%m: %s"));
3107        assert_eq!(cfg.pager.wrap, Some(78));
3108        assert!(cfg.pager.tilde);
3109        assert!(toml.contains("hdr_order"));
3110    }
3111
3112    #[test]
3113    fn compose_round_two_translates() {
3114        let (cfg, _) = to_config(concat!(
3115            "set fast_reply = yes\n",
3116            "set autoedit\n",
3117            "set nocopy\n",
3118            "set mime_forward = ask-yes\n",
3119            "set forward_decode\n", // satisfied: always decoded
3120            "set new_mail_command=\"notify-send 'rmut: %n new in %f'\"\n",
3121        ));
3122        assert!(cfg.mail.fast_reply);
3123        assert!(cfg.mail.autoedit);
3124        assert_eq!(cfg.mail.copy, Some(false));
3125        assert_eq!(cfg.mail.forward.as_deref(), Some("ask"));
3126        assert_eq!(
3127            cfg.mail.new_mail_command.as_deref(),
3128            Some("notify-send 'rmut: %n new in %f'")
3129        );
3130    }
3131
3132    #[test]
3133    fn the_signature_and_the_send_questions_import() {
3134        let (cfg, toml) = to_config(concat!(
3135            "set signature = \"~/.signature\"\n",
3136            "set nosig_dashes\n",
3137            "set forward_quote\n",
3138            "set abort_nosubject = no\n",
3139            "set noabort_unmodified\n",
3140        ));
3141        assert_eq!(cfg.mail.signature.as_deref(), Some("~/.signature"));
3142        assert_eq!(cfg.mail.sig_dashes, Some(false));
3143        assert!(cfg.mail.forward_quote);
3144        assert_eq!(cfg.mail.abort_nosubject.as_deref(), Some("no"));
3145        assert_eq!(cfg.mail.abort_unmodified, Some(false));
3146        assert!(toml.contains("signature = \"~/.signature\""), "{toml}");
3147    }
3148
3149    #[test]
3150    fn what_rmut_already_asks_is_satisfied_not_skipped() {
3151        // The mutt defaults for these four are rmut's behaviour, so
3152        // they belong in neither the config nor the skipped list.
3153        let (cfg, toml) = to_config(concat!(
3154            "set reply_to = ask-yes\n",
3155            "set honor_followup_to = yes\n",
3156            "set abort_nosubject = ask-yes\n",
3157            "set abort_unmodified = yes\n",
3158            "set sig_dashes = yes\n",
3159            "set noforward_quote\n",
3160        ));
3161        assert_eq!(cfg.mail.abort_nosubject, None);
3162        assert_eq!(cfg.mail.abort_unmodified, None);
3163        assert_eq!(cfg.mail.sig_dashes, None);
3164        assert!(!cfg.mail.forward_quote);
3165        let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3166        assert!(unclaimed.trim().is_empty(), "{toml}");
3167        assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3168        // reply_to's other three are rmut answering it for you.
3169        let (_, toml) = to_config("set reply_to = yes\n");
3170        let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3171        assert!(unclaimed.contains("set reply_to = yes"), "{toml}");
3172    }
3173
3174    #[test]
3175    fn the_small_habits_import_or_are_already_rmut() {
3176        let (cfg, toml) = to_config(concat!(
3177            "set nomark_old\n",
3178            "set beep_new = yes\n",
3179            "set nowait_key\n",
3180            "set print = ask-yes\n",
3181            "set noreverse_realname\n",
3182        ));
3183        assert_eq!(cfg.mail.mark_old, Some(false));
3184        assert!(cfg.ui.beep_new);
3185        assert_eq!(cfg.ui.wait_key, Some(false));
3186        assert_eq!(cfg.mail.print_confirm.as_deref(), Some("ask-yes"));
3187        assert_eq!(cfg.identity.reverse_realname, Some(false));
3188        assert!(toml.contains("beep_new = true"), "{toml}");
3189
3190        // The mutt defaults for all five are what rmut does anyway,
3191        // and $timeout is a poll rmut runs its own way.
3192        let (_, toml) = to_config(concat!(
3193            "set mark_old = yes\n",
3194            "set nobeep_new\n",
3195            "set wait_key = yes\n",
3196            "set print = ask-no\n",
3197            "set reverse_realname = yes\n",
3198            "set timeout = 15\n",
3199        ));
3200        let unclaimed = toml.split("# not imported:").nth(1).unwrap_or_default();
3201        assert!(unclaimed.trim().is_empty(), "{toml}");
3202        assert!(toml.contains("#   set timeout = 15  (rmut polls"), "{toml}");
3203    }
3204
3205    #[test]
3206    fn pgp_settings_translate() {
3207        let (cfg, _) = to_config(concat!(
3208            "set pgp_sign_as = 0xDEADBEEF\n",
3209            "set crypt_autosign = yes\n",
3210            "set nocrypt_autoencrypt\n",
3211        ));
3212        assert_eq!(cfg.pgp.sign_key.as_deref(), Some("0xDEADBEEF"));
3213        assert!(cfg.pgp.sign_by_default);
3214        assert!(!cfg.pgp.encrypt_by_default);
3215    }
3216
3217    #[test]
3218    fn what_rmut_has_is_imported_and_what_it_does_its_own_way_is_said() {
3219        let (cfg, toml) = to_config(concat!(
3220            "set sidebar_visible = yes\n",
3221            "set sidebar_width = 24\n",
3222            "set sidebar_format = \"%B%* %N\"\n",
3223            "set alias_file = ~/.mutt/aliases\n",
3224            "set imap_idle = yes\n",
3225            "set header_cache = ~/.cache/mutt\n",
3226            "set crypt_use_gpgme = yes\n",
3227            "set implicit_autoview = yes\n",
3228        ));
3229        // The settings rmut has come across.
3230        assert!(cfg.sidebar.visible, "{toml}");
3231        assert_eq!(cfg.sidebar.width, 24);
3232        assert_eq!(cfg.mail.alias_file.as_deref(), Some("~/.mutt/aliases"));
3233        // The ones it satisfies say so, rather than reading as holes.
3234        assert!(
3235            toml.contains("rmut IDLEs whenever the server offers it"),
3236            "{toml}"
3237        );
3238        assert!(toml.contains("# rmut does these its own way:"), "{toml}");
3239        assert!(toml.contains("under ~/.cache/rmut"), "{toml}");
3240        assert!(toml.contains("gpg(1) directly"), "{toml}");
3241        assert!(toml.contains("no format string"), "{toml}");
3242        // And none of them is claimed as not imported.
3243        let not_imported = toml.split("# not imported:").nth(1).unwrap_or("");
3244        for gone in [
3245            "sidebar_visible",
3246            "imap_idle",
3247            "header_cache",
3248            "crypt_use_gpgme",
3249        ] {
3250            assert!(!not_imported.contains(gone), "{gone} still skipped: {toml}");
3251        }
3252    }
3253
3254    #[test]
3255    fn the_reply_and_forward_text_carries_over() {
3256        let (cfg, toml) = to_config(concat!(
3257            "set attribution = \"On %d, %n wrote:\"\n",
3258            "set indent_string = \"| \"\n",
3259            "set forward_format = \"Fwd: %s\"\n",
3260            "set include = no\n",
3261            "set askcc = yes\n",
3262            "set askbcc = yes\n",
3263        ));
3264        assert_eq!(
3265            cfg.mail.attribution.as_deref(),
3266            Some("On %d, %n wrote:"),
3267            "{toml}"
3268        );
3269        assert_eq!(cfg.mail.indent_string.as_deref(), Some("| "));
3270        assert_eq!(cfg.mail.forward_format.as_deref(), Some("Fwd: %s"));
3271        assert_eq!(cfg.mail.include.as_deref(), Some("no"));
3272        assert!(cfg.mail.ask_cc && cfg.mail.ask_bcc);
3273        // A quadoption rmut cannot make sense of is reported, not
3274        // guessed at.
3275        let import = import("set include = maybe\n", Path::new("/nonexistent"));
3276        assert!(
3277            import
3278                .toml
3279                .contains("include wants yes / no / ask-yes / ask-no"),
3280            "{}",
3281            import.toml
3282        );
3283    }
3284
3285    #[test]
3286    fn connect_timeout_carries_over() {
3287        let (cfg, toml) = to_config("set connect_timeout=15\n");
3288        assert_eq!(cfg.net.connect_timeout, 15, "{toml}");
3289        // mutt waits for the OS when it is zero or less.
3290        let (cfg, _) = to_config("set connect_timeout=-1\n");
3291        assert_eq!(cfg.net.connect_timeout, 0);
3292    }
3293
3294    #[test]
3295    fn wrap_search_off_carries_over() {
3296        let (cfg, toml) = to_config("set nowrap_search\n");
3297        assert_eq!(cfg.mail.wrap_search, Some(false), "{toml}");
3298    }
3299
3300    #[test]
3301    fn simple_search_carries_over() {
3302        let (cfg, toml) = to_config("set simple_search = \"~f %s | ~s %s | ~b %s\"\n");
3303        assert_eq!(
3304            cfg.mail.simple_search.as_deref(),
3305            Some("~f %s | ~s %s | ~b %s"),
3306            "{toml}"
3307        );
3308    }
3309
3310    #[test]
3311    fn search_context_carries_over() {
3312        let (cfg, toml) = to_config("set search_context = 3\n");
3313        assert_eq!(cfg.pager.search_context, 3, "{toml}");
3314    }
3315
3316    #[test]
3317    fn hide_thread_subject_carries_over() {
3318        let (cfg, toml) = to_config("set hide_thread_subject = yes\n");
3319        assert_eq!(cfg.index.hide_thread_subject, Some(true), "{toml}");
3320    }
3321
3322    #[test]
3323    fn mark_options_carry_over() {
3324        // R85: the non-default side of each comes across; the default
3325        // side is satisfied and emits nothing.
3326        let (cfg, toml) = to_config(
3327            "set nodelete_untag\nset flag_safe\nset maildir_trash\n\
3328             set nomail_check_recent\nset nocheck_new\nset nouncollapse_new\n",
3329        );
3330        assert_eq!(cfg.mail.delete_untag, Some(false), "{toml}");
3331        assert!(cfg.mail.flag_safe);
3332        assert!(cfg.mail.maildir_trash);
3333        assert_eq!(cfg.mail.mail_check_recent, Some(false));
3334        assert_eq!(cfg.mail.check_new, Some(false));
3335        assert_eq!(cfg.index.uncollapse_new, Some(false));
3336        let (cfg, toml) = to_config(
3337            "set delete_untag\nset noflag_safe\nset nomaildir_trash\n\
3338             set mail_check_recent\nset check_new\nset uncollapse_new\n",
3339        );
3340        assert_eq!(cfg.mail.delete_untag, None, "{toml}");
3341        assert!(!cfg.mail.flag_safe);
3342        assert!(!cfg.mail.maildir_trash);
3343        assert_eq!(cfg.index.uncollapse_new, None);
3344        assert!(!toml.contains("not imported"), "{toml}");
3345    }
3346
3347    #[test]
3348    fn menu_knobs_carry_over() {
3349        let (cfg, toml) =
3350            to_config("set nomenu_scroll\nset menu_context = 3\nset nomenu_move_off\nset nohelp\n");
3351        assert_eq!(cfg.ui.menu_scroll, Some(false), "{toml}");
3352        assert_eq!(cfg.ui.menu_context, 3);
3353        assert_eq!(cfg.ui.menu_move_off, Some(false));
3354        assert_eq!(cfg.ui.help, Some(false));
3355        // The ones rmut answers another way are reported, not holes.
3356        let (_, toml) = to_config(
3357            "set keep_flagged\nset thread_received\nset sleep_time = 0\nset read_inc = 100\n\
3358             set hide_limited\n",
3359        );
3360        assert!(!toml.contains("not imported"), "{toml}");
3361        assert!(toml.contains("no $move"), "{toml}");
3362    }
3363
3364    #[test]
3365    fn browser_alias_shell_tmpdir_carry_over() {
3366        let (cfg, toml) = to_config(
3367            "set sort_browser = reverse-date\nset sort_alias = alias\nset shell = /bin/zsh\n\
3368             set tmpdir = ~/tmp\n",
3369        );
3370        assert_eq!(
3371            cfg.ui.sort_browser.as_deref(),
3372            Some("reverse-date"),
3373            "{toml}"
3374        );
3375        assert_eq!(cfg.mail.sort_alias.as_deref(), Some("alias"));
3376        assert_eq!(cfg.mail.shell.as_deref(), Some("/bin/zsh"));
3377        assert_eq!(cfg.mail.tmpdir.as_deref(), Some("~/tmp"));
3378        let (cfg, _) = to_config("set ispell = \"aspell -c\"\n");
3379        assert_eq!(cfg.mail.ispell.as_deref(), Some("aspell -c"));
3380        let (_, toml) = to_config("set sort_browser = alpha\nset sort_alias = address\n");
3381        assert!(!toml.contains("not imported"), "{toml}");
3382    }
3383
3384    #[test]
3385    fn the_threading_knobs_carry_over() {
3386        let (cfg, toml) = to_config("set strict_threads = yes\nset nosort_re\n");
3387        assert_eq!(cfg.index.strict_threads, Some(true), "{toml}");
3388        assert_eq!(cfg.index.sort_re, Some(false), "{toml}");
3389        // Their defaults are rmut's too, so they are satisfied rather
3390        // than written out.
3391        let (cfg, toml) = to_config("set nostrict_threads\nset sort_re = yes\n");
3392        assert_eq!(cfg.index.strict_threads, None, "{toml}");
3393        assert_eq!(cfg.index.sort_re, None, "{toml}");
3394        assert!(!toml.contains("not imported"), "{toml}");
3395    }
3396
3397    #[test]
3398    fn status_chars_carries_over() {
3399        let (cfg, toml) = to_config("set status_chars = \"-*%A\"\n");
3400        assert_eq!(cfg.ui.status_chars.as_deref(), Some("-*%A"), "{toml}");
3401    }
3402
3403    #[test]
3404    fn layout_settings_carry_over() {
3405        let (cfg, toml) = to_config("set status_on_top = yes\nset arrow_cursor = yes\n");
3406        assert_eq!(cfg.ui.status_on_top, Some(true), "{toml}");
3407        assert_eq!(cfg.ui.arrow_cursor, Some(true));
3408    }
3409
3410    #[test]
3411    fn history_file_carries_over() {
3412        let (cfg, toml) = to_config("set history_file = ~/.rmut_history\n");
3413        assert_eq!(
3414            cfg.ui.history_file.as_deref(),
3415            Some("~/.rmut_history"),
3416            "{toml}"
3417        );
3418    }
3419
3420    #[test]
3421    fn ts_title_settings_carry_over() {
3422        let (cfg, toml) = to_config(concat!(
3423            "set ts_enabled = yes\n",
3424            "set ts_status_format = \"rmut %f (%m)\"\n",
3425        ));
3426        assert_eq!(cfg.ui.set_title, Some(true), "{toml}");
3427        assert_eq!(cfg.ui.title_format.as_deref(), Some("rmut %f (%m)"));
3428    }
3429
3430    #[test]
3431    fn reply_crypto_settings_carry_over() {
3432        let (cfg, toml) = to_config(concat!(
3433            "set crypt_replysign = yes\n",
3434            "set crypt_replyencrypt = yes\n",
3435        ));
3436        assert!(cfg.pgp.reply_sign, "{toml}");
3437        assert!(cfg.pgp.reply_encrypt);
3438        assert!(!cfg.pgp.reply_sign_encrypted);
3439    }
3440
3441    #[test]
3442    fn postpone_and_recall_quadoptions_carry_over() {
3443        let (cfg, toml) = to_config(concat!("set postpone = no\n", "set recall = yes\n",));
3444        assert_eq!(cfg.mail.postpone.as_deref(), Some("no"), "{toml}");
3445        assert_eq!(cfg.mail.recall.as_deref(), Some("yes"));
3446        // The defaults (postpone ask-yes, recall ask) are satisfied,
3447        // not carried.
3448        let (cfg, _) = to_config(concat!("set postpone = ask-yes\n", "set recall = ask-no\n",));
3449        assert_eq!(cfg.mail.postpone, None);
3450        assert_eq!(cfg.mail.recall, None);
3451    }
3452
3453    #[test]
3454    fn the_envelope_settings_carry_over() {
3455        let (cfg, toml) = to_config(concat!(
3456            "set hostname = mail.example.net\n",
3457            "set user_agent = yes\n",
3458            "set sig_on_top = yes\n",
3459        ));
3460        assert_eq!(
3461            cfg.mail.hostname.as_deref(),
3462            Some("mail.example.net"),
3463            "{toml}"
3464        );
3465        assert_eq!(cfg.mail.user_agent, Some(true));
3466        assert_eq!(cfg.mail.sig_on_top, Some(true));
3467    }
3468
3469    #[test]
3470    fn the_send_odds_carry_over() {
3471        let (cfg, toml) = to_config(concat!(
3472            "set envelope_from\n",
3473            "set envelope_from_address = \"bounces@example.com\"\n",
3474            "set dsn_notify = \"failure,delay\"\n",
3475            "set dsn_return = hdrs\n",
3476            "set reply_self\n",
3477            "set fcc_attach = ask-no\n",
3478            "set fcc_clear\n",
3479            "set forward_edit = no\n",
3480            "set nomime_forward_rest\n",
3481        ));
3482        let mail = &cfg.mail;
3483        assert!(mail.use_envelope_from, "{toml}");
3484        assert_eq!(
3485            mail.envelope_from_address.as_deref(),
3486            Some("bounces@example.com")
3487        );
3488        assert_eq!(mail.dsn_notify.as_deref(), Some("failure,delay"));
3489        assert_eq!(mail.dsn_return.as_deref(), Some("hdrs"));
3490        assert!(mail.reply_self && mail.fcc_clear);
3491        assert_eq!(mail.fcc_attach.as_deref(), Some("ask-no"));
3492        assert_eq!(mail.forward_edit.as_deref(), Some("no"));
3493        assert_eq!(mail.mime_forward_rest, Some(false));
3494        let import = import("set forward_edit = maybe\n", Path::new("/nonexistent"));
3495        assert!(
3496            import.toml.contains("a quadoption wants"),
3497            "{}",
3498            import.toml
3499        );
3500    }
3501
3502    #[test]
3503    fn the_trust_settings_carry_over() {
3504        let (cfg, toml) = to_config(concat!(
3505            "set certificate_file = ~/.mutt/certs.pem\n",
3506            "set ssl_usesystemcerts = no\n",
3507        ));
3508        assert_eq!(
3509            cfg.net.certificate_file.as_deref(),
3510            Some("~/.mutt/certs.pem"),
3511            "{toml}"
3512        );
3513        assert!(!cfg.net.system_cas, "{toml}");
3514        // ssl_ca_certificates_file is the same slot, and yes is
3515        // satisfied rather than carried.
3516        let (cfg, _) = to_config(concat!(
3517            "set ssl_ca_certificates_file = /etc/ssl/roots.pem\n",
3518            "set ssl_usesystemcerts = yes\n",
3519        ));
3520        assert_eq!(
3521            cfg.net.certificate_file.as_deref(),
3522            Some("/etc/ssl/roots.pem")
3523        );
3524        assert!(cfg.net.system_cas);
3525    }
3526
3527    #[test]
3528    fn imap_folder_becomes_an_account() {
3529        let (cfg, toml) = to_config(concat!(
3530            "set folder = imaps://mail.example.com\n",
3531            "set spoolfile = +INBOX\n",
3532            "set imap_user = jane\n",
3533            "set imap_pass = hunter2\n",
3534            "set smtp_url = smtps://jane@smtp.example.com:465\n",
3535            "set record = +Sent\n",
3536            "mailboxes +INBOX +Archive\n",
3537        ));
3538        let acct = cfg.account("mutt").unwrap();
3539        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3540        assert_eq!(acct.user, "jane");
3541        assert_eq!(acct.smtp_host, Some("smtp.example.com".into()));
3542        assert_eq!(acct.smtp_port, 465);
3543        assert_eq!(acct.sent_folder, "Sent");
3544        assert_eq!(
3545            cfg.mail.mailboxes,
3546            vec!["imap:mutt/INBOX", "imap:mutt/Archive"]
3547        );
3548        // imap_pass carries over as the stored password.
3549        assert_eq!(acct.password.as_deref(), Some("hunter2"));
3550        assert_eq!(acct.password().unwrap(), "hunter2");
3551        assert!(toml.contains("consider password_command"), "{toml}");
3552    }
3553
3554    #[test]
3555    fn plain_imap_url_means_starttls_port_never_disabled_tls() {
3556        let (cfg, toml) = to_config(concat!(
3557            "set folder = imap://mail.example.com\n",
3558            "set imap_user = u\n",
3559            "set smtp_url = smtps://smtp.example.com\n",
3560        ));
3561        let acct = cfg.account("mutt").unwrap();
3562        assert_eq!(acct.imap_port, 143);
3563        assert!(acct.imap_tls, "STARTTLS, not plaintext");
3564        assert!(!toml.contains("imap_tls"), "{toml}");
3565        assert_eq!(acct.smtp_port, 465);
3566    }
3567
3568    #[test]
3569    fn sort_pager_and_forward_directives_import() {
3570        let (cfg, toml) = to_config(concat!(
3571            "set sort                = \"threads\"\n",
3572            "set sort_aux            = last-date-sent\n",
3573            "set date_format         = \"%d.%m.%Y\"\n",
3574            "set pager_index_lines   = 10\n",
3575            "set pager_context       = 3\n",
3576            "set mime_forward        = yes\n",
3577            "set mime_forward_rest   = yes\n",
3578            "set query_command       = \"khard email --parsable %s\"\n",
3579            "set trash               = +Trash\n",
3580            "set edit_headers        = yes\n",
3581            "save-hook . +General\n",
3582            "set folder = ~/Mail\n",
3583            "bind index G imap-fetch-mail\n",
3584        ));
3585        assert_eq!(cfg.index.sort.as_deref(), Some("threads"));
3586        assert_eq!(cfg.index.sort_aux.as_deref(), Some("last-date-sent"));
3587        assert_eq!(cfg.index.date_format.as_deref(), Some("%d.%m.%Y"));
3588        assert_eq!(cfg.pager.index_lines, 10);
3589        assert_eq!(cfg.pager.context, 3);
3590        assert_eq!(cfg.mail.forward.as_deref(), Some("attach"));
3591        assert_eq!(
3592            cfg.mail.query_command.as_deref(),
3593            Some("khard email --parsable %s")
3594        );
3595        assert_eq!(cfg.mail.save.as_deref(), Some("~/Mail/General"));
3596        assert_eq!(cfg.mail.trash.as_deref(), Some("~/Mail/Trash"));
3597        assert_eq!(cfg.mail.edit_headers, Some(true));
3598        // edit_headers = no matches rmut's default.
3599        let (cfg2, _) = to_config("set edit_headers = no\n");
3600        assert!(cfg2.mail.edit_headers.is_none());
3601        assert_eq!(
3602            cfg.keys.index.get("fetch-mail").map(String::as_str),
3603            Some("G")
3604        );
3605        assert!(toml.contains("mime_forward_rest"), "{toml}");
3606        assert!(!toml.contains("# not imported"), "{toml}");
3607    }
3608
3609    #[test]
3610    fn auto_view_and_peek_and_tagged_color() {
3611        let (cfg, toml) = to_config(concat!(
3612            "auto_view application/zip\n",
3613            "auto_view text/x-patch text/x-diff\n",
3614            "auto_view application/pgp-signature application/pgp\n",
3615            "auto_view text/html\n",
3616            "auto_view text/calendar\n",
3617            "unauto_view text/calendar\n",
3618            "alternative_order text/enriched text/plain TEXT/HTML\n",
3619            "unalternative_order text/enriched\n",
3620            "set imap_peek           = yes\n",
3621            "set menu_scroll\n",
3622            "bind index % noop\n",
3623            "color index black   cyan    \"~T\"\n",
3624        ));
3625        // Every auto_view type becomes a [filters] entry; the command
3626        // is left empty, which is rmut's "take it from mailcap", the
3627        // very place mutt takes it from.
3628        for mime in [
3629            "application/zip",
3630            "text/x-patch",
3631            "text/x-diff",
3632            "text/html",
3633        ] {
3634            assert_eq!(
3635                cfg.filters.get(mime).map(String::as_str),
3636                Some(""),
3637                "{mime}"
3638            );
3639        }
3640        // unauto_view takes one back off.
3641        assert!(!cfg.filters.contains_key("text/calendar"), "{toml}");
3642        assert_eq!(cfg.pager.alternative_order, ["text/plain", "text/html"]);
3643        // black-on-cyan keeps both colours, as a rule: the slot holds
3644        // one, and mutt painted a bar.
3645        let tagged = cfg
3646            .color_index
3647            .iter()
3648            .find(|r| r.pattern == "~T")
3649            .expect("a ~T rule");
3650        assert_eq!(tagged.fg.as_deref(), Some("black"));
3651        assert_eq!(tagged.bg.as_deref(), Some("cyan"));
3652        assert!(!cfg.colors.contains_key("tagged"));
3653        assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3654        for satisfied in ["pgp-signature", "imap_peek", "menu_scroll", "noop"] {
3655            assert!(toml.contains(satisfied), "{satisfied} missing:\n{toml}");
3656        }
3657        assert!(toml.contains("application/zip"), "{toml}");
3658    }
3659
3660    #[test]
3661    fn imap_pass_without_imap_folder_is_redacted() {
3662        let (cfg, toml) = to_config("set folder = ~/Mail\nset imap_pass = hunter2\n");
3663        assert!(cfg.accounts.is_empty());
3664        assert!(!toml.contains("hunter2"), "password must not leak:\n{toml}");
3665        assert!(toml.contains("(redacted)"), "{toml}");
3666    }
3667
3668    #[test]
3669    fn url_style_spoolfile_record_and_mailboxes() {
3670        // The common mutt style: full URLs everywhere, no +shortcuts.
3671        let (cfg, toml) = to_config(concat!(
3672            "set folder = \"imaps://mail.example.com/\"\n",
3673            "set spoolfile = \"imaps://mail.example.com/INBOX\"\n",
3674            "set record = \"imaps://mail.example.com/Sent\"\n",
3675            "set imap_user = jane\n",
3676            "mailboxes imaps://mail.example.com/INBOX imaps://mail.example.com/Archive\n",
3677        ));
3678        let acct = cfg.account("mutt").unwrap();
3679        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3680        assert_eq!(acct.sent_folder, "Sent");
3681        assert_eq!(
3682            cfg.mail.mailboxes,
3683            vec!["imap:mutt/INBOX", "imap:mutt/Archive"]
3684        );
3685        assert!(!toml.contains("//INBOX"), "no doubled separators:\n{toml}");
3686    }
3687
3688    #[test]
3689    fn url_with_userinfo_and_empty_port() {
3690        // Seen in the wild: user@ in the URL and a dangling colon.
3691        let (cfg, toml) = to_config(concat!(
3692            "set folder = \"imap://jane@mail.example.com:/\"\n",
3693            "set spoolfile = \"imap://jane@mail.example.com:/INBOX\"\n",
3694        ));
3695        let acct = cfg.account("mutt").unwrap();
3696        assert_eq!(acct.user, "jane");
3697        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3698        assert_eq!(acct.imap_port, 143);
3699        assert!(acct.imap_tls, "imap:// means STARTTLS, not plaintext");
3700        assert_eq!(cfg.mail.mailboxes, vec!["imap:mutt/INBOX"]);
3701        assert!(
3702            !toml.contains("jane@mail.example.com"),
3703            "no URLs in specs:\n{toml}"
3704        );
3705    }
3706
3707    #[test]
3708    fn url_spoolfile_alone_identifies_the_server() {
3709        let (cfg, _) = to_config(concat!(
3710            "set spoolfile = imaps://mail.example.com:1993/INBOX\n",
3711            "set imap_user = jane\n",
3712        ));
3713        let acct = cfg.account("mutt").unwrap();
3714        assert_eq!(acct.imap_host, Some("mail.example.com".into()));
3715        assert_eq!(acct.imap_port, 1993);
3716        assert_eq!(cfg.mail.mailboxes, vec!["imap:mutt/INBOX"]);
3717    }
3718
3719    #[test]
3720    fn smtp_only_muttrc_still_gets_an_account() {
3721        let (cfg, _) = to_config(concat!(
3722            "set folder = ~/Mail\n",
3723            "set from = jane@x\n",
3724            "set smtp_url = smtp://smtp.example.com:587\n",
3725            "set smtp_pass = sekrit\n",
3726        ));
3727        let acct = cfg.account("mutt").unwrap();
3728        assert!(acct.imap_host.is_none());
3729        assert_eq!(acct.smtp_host, Some("smtp.example.com".into()));
3730        assert_eq!(acct.user, "jane@x");
3731        assert_eq!(acct.password.as_deref(), Some("sekrit"));
3732    }
3733
3734    #[test]
3735    fn differing_smtp_pass_is_noted_and_redacted() {
3736        let (cfg, toml) = to_config(concat!(
3737            "set folder = imaps://h\n",
3738            "set imap_user = u\n",
3739            "set imap_pass = aaa\n",
3740            "set smtp_url = smtp://s\n",
3741            "set smtp_pass = bbb\n",
3742        ));
3743        assert_eq!(
3744            cfg.account("mutt").unwrap().password.as_deref(),
3745            Some("aaa")
3746        );
3747        assert!(!toml.contains("bbb"), "smtp_pass must not leak:\n{toml}");
3748        assert!(toml.contains("differs from imap_pass"), "{toml}");
3749    }
3750
3751    #[test]
3752    fn default_matching_directives_are_acknowledged() {
3753        let (cfg, toml) = to_config(concat!(
3754            "set ssl_starttls        = yes\n",
3755            "set ssl_force_tls       = yes\n",
3756            "set charset             = \"UTF-8\"\n",
3757            "set pgp_auto_decode     = yes\n",
3758            "set smtp_authenticators =\"login\"\n",
3759            "set print_command       = \"a2ps\"\n",
3760        ));
3761        assert_eq!(cfg.mail.print.as_deref(), Some("a2ps"));
3762        assert!(toml.contains("# satisfied by rmut's defaults"), "{toml}");
3763        for directive in [
3764            "ssl_starttls",
3765            "ssl_force_tls",
3766            "charset",
3767            "pgp_auto_decode",
3768            "smtp_authenticators",
3769        ] {
3770            assert!(toml.contains(directive), "{directive} missing:\n{toml}");
3771        }
3772        assert!(!toml.contains("# not imported"), "{toml}");
3773    }
3774
3775    #[test]
3776    fn non_default_tls_and_charset_still_surface() {
3777        let (_cfg, toml) = to_config(concat!(
3778            "set ssl_force_tls = no\n",
3779            "set charset = \"iso-8859-2\"\n",
3780            "set smtp_authenticators = \"oauthbearer\"\n",
3781        ));
3782        assert!(toml.contains("# not imported:"), "{toml}");
3783        assert!(!toml.contains("# satisfied"), "{toml}");
3784    }
3785
3786    #[test]
3787    fn account_without_imap_pass_gets_a_placeholder() {
3788        let (cfg, toml) = to_config("set folder = imaps://h.example.com\nset imap_user = u\n");
3789        assert!(cfg.account("mutt").unwrap().password_command.is_some());
3790        assert!(toml.contains("pass show mail/TODO"), "{toml}");
3791    }
3792
3793    #[test]
3794    fn aliases_and_unknowns_surface_as_comments() {
3795        let import = import(
3796            "alias petr Petr Novak <petr@example.com>\nset sleep_time = 0\nmacro index x \"<shell-escape>ls\\n\"\n",
3797            Path::new("/"),
3798        );
3799        // Aliases come back on their own: rmut keeps them in a
3800        // mutt-format alias file, so the caller either writes that
3801        // file or shows them with alias_block.
3802        assert_eq!(import.aliases, ["alias petr Petr Novak <petr@example.com>"]);
3803        assert!(!import.toml.contains("alias petr"), "{}", import.toml);
3804        let block = alias_block(&import.aliases, Path::new("/home/x/.config/rmut/aliases"));
3805        assert!(
3806            block.contains("#   alias petr Petr Novak <petr@example.com>"),
3807            "{block}"
3808        );
3809        assert!(block.contains("/home/x/.config/rmut/aliases"), "{block}");
3810        assert!(alias_block(&[], Path::new("/x")).is_empty());
3811        assert!(import.toml.contains("set sleep_time = 0"));
3812        assert!(import.toml.contains("macro index x"), "{}", import.toml);
3813        assert!(import.toml.contains("mutt function names"));
3814        // and the output is still valid (if empty) config
3815        toml::from_str::<Config>(&import.toml).unwrap();
3816    }
3817
3818    #[test]
3819    fn source_includes_are_followed() {
3820        let tmp = tempfile::tempdir().unwrap();
3821        std::fs::write(tmp.path().join("extra"), "set realname = Included\n").unwrap();
3822        let import = import("source extra\nsource ./missing\n", tmp.path());
3823        assert!(import.toml.contains("name = \"Included\""));
3824        assert!(import.toml.contains("source ./missing"));
3825    }
3826
3827    #[test]
3828    fn continuations_and_quoting() {
3829        let lines = logical_lines("set realname = \\\n  \"Jane # not a comment\"\n# gone\n");
3830        assert_eq!(lines, vec!["set realname =   \"Jane # not a comment\""]);
3831        assert_eq!(
3832            tokenize("bind index \\Cd delete-message"),
3833            vec!["bind", "index", "\\Cd", "delete-message"]
3834        );
3835        assert_eq!(tokenize("set from='a b' c"), vec!["set", "from=a b", "c"]);
3836    }
3837
3838    #[test]
3839    fn convert_key_forms() {
3840        assert_eq!(convert_key("\\Cx").as_deref(), Some("ctrl+x"));
3841        assert_eq!(convert_key("\\CX").as_deref(), Some("ctrl+x"));
3842        assert_eq!(convert_key("\\ev").as_deref(), Some("alt+v"));
3843        assert_eq!(convert_key("<esc>V").as_deref(), Some("alt+V"));
3844        assert_eq!(convert_key("<Enter>").as_deref(), Some("enter"));
3845        assert_eq!(convert_key("<PageDown>").as_deref(), Some("pgdn"));
3846        assert_eq!(convert_key("G").as_deref(), Some("G"));
3847        assert_eq!(convert_key("gg"), None);
3848        assert_eq!(convert_key("<f5>"), None);
3849    }
3850}