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