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