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