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