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