1use crate::config::{ColorRule, Config};
13use crate::muttrc::{assignments, convert_color, is_yes, split_from, tokenize};
14
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Menu {
19 Index,
20 Pager,
21 Generic,
22}
23
24impl Menu {
25 fn parse(name: &str) -> Option<Menu> {
26 match name {
27 "index" => Some(Menu::Index),
28 "pager" => Some(Menu::Pager),
29 "generic" => Some(Menu::Generic),
30 _ => None,
31 }
32 }
33
34 pub fn name(self) -> &'static str {
35 match self {
36 Menu::Index => "index",
37 Menu::Pager => "pager",
38 Menu::Generic => "generic",
39 }
40 }
41}
42
43#[derive(Clone, Debug, PartialEq)]
46pub enum Command {
47 Set {
48 name: String,
49 value: String,
50 },
51 Unset(String),
52 Toggle(String),
53 Query(String),
55 Bind {
56 menu: Menu,
57 key: String,
58 function: String,
59 },
60 Macro {
61 menu: Menu,
62 key: String,
63 seq: String,
64 },
65 Color {
66 object: String,
67 fg: String,
68 bg: String,
69 pattern: Option<String>,
70 },
71 Ignore(Vec<String>),
72 Unignore(Vec<String>),
73 Uncolor {
76 object: String,
77 pattern: Option<String>,
78 },
79 Unhook(String),
81 Unmailboxes(Vec<String>),
83 Unalias(Vec<String>),
85 Reset(String),
87 Alternates(Vec<String>),
90 Unalternates(Vec<String>),
91 AutoView(Vec<String>),
95 UnAutoView(Vec<String>),
96 AlternativeOrder(Vec<String>),
99 UnAlternativeOrder(Vec<String>),
100 MyHdr(String),
102 UnMyHdr(Vec<String>),
104 Alias {
105 nick: String,
106 expansion: String,
107 },
108 Push(String),
110 Exec(String),
112}
113
114pub fn parse(line: &str) -> Result<Vec<Command>, String> {
118 let trimmed = line.trim();
119 if trimmed.is_empty() || trimmed.starts_with('#') {
120 return Ok(Vec::new());
121 }
122 let tokens = tokenize(trimmed);
123 let Some(cmd) = tokens.first() else {
124 return Ok(Vec::new());
125 };
126 let args = &tokens[1..];
127 match cmd.as_str() {
128 "set" => {
129 if args.is_empty() {
130 return Err("set what?".into());
131 }
132 Ok(assignments(args).into_iter().map(one_set).collect())
133 }
134 "unset" => want(args, "unset what?").map(|names| {
135 names
136 .iter()
137 .map(|n| Command::Unset(n.to_string()))
138 .collect()
139 }),
140 "reset" => want(args, "reset what?").map(|names| {
141 names
142 .iter()
143 .map(|n| Command::Reset(n.to_string()))
144 .collect()
145 }),
146 "uncolor" | "unmono" => {
147 let Some(object) = args.first() else {
148 return Err(format!("{cmd} OBJECT [PATTERN|*]"));
149 };
150 Ok(vec![Command::Uncolor {
151 object: object.clone(),
152 pattern: (args.len() > 1).then(|| args[1..].join(" ")),
153 }])
154 }
155 "mono" => {
156 let (Some(object), Some(attr)) = (args.first(), args.get(1)) else {
160 return Err("mono OBJECT ATTRIBUTE [PATTERN]".into());
161 };
162 if !matches!(
163 attr.as_str(),
164 "none" | "bold" | "underline" | "reverse" | "standout"
165 ) {
166 return Err(format!("mono: no such attribute {attr:?}"));
167 }
168 Ok(vec![Command::Color {
169 object: object.clone(),
170 fg: attr.clone(),
171 bg: "default".into(),
172 pattern: (args.len() > 2).then(|| args[2..].join(" ")),
173 }])
174 }
175 "unhook" => want(args, "unhook TYPE|*").map(|types| {
176 types
177 .iter()
178 .map(|t| Command::Unhook(t.to_string()))
179 .collect()
180 }),
181 "unmailboxes" => {
182 want(args, "unmailboxes MAILBOX...|*").map(|m| vec![Command::Unmailboxes(m)])
183 }
184 "unalias" => want(args, "unalias NICK...|*").map(|n| vec![Command::Unalias(n)]),
185 "toggle" => want(args, "toggle what?").map(|names| {
186 names
187 .iter()
188 .map(|n| Command::Toggle(n.to_string()))
189 .collect()
190 }),
191 "bind" | "macro" => {
192 let (Some(menus), Some(key)) = (args.first(), args.get(1)) else {
193 return Err(format!("{cmd} MENU KEY ..."));
194 };
195 let Some(rest) = args.get(2) else {
196 return Err(if cmd == "bind" {
197 "bind MENU KEY FUNCTION".into()
198 } else {
199 "macro MENU KEY SEQUENCE".into()
200 });
201 };
202 let mut out = Vec::new();
203 for name in menus.split(',') {
204 let Some(menu) = Menu::parse(name) else {
205 return Err(format!("unknown menu {name:?}"));
206 };
207 out.push(if cmd == "bind" {
208 Command::Bind {
209 menu,
210 key: key.clone(),
211 function: rest.clone(),
212 }
213 } else {
214 Command::Macro {
215 menu,
216 key: key.clone(),
217 seq: rest.clone(),
218 }
219 });
220 }
221 Ok(out)
222 }
223 "color" => {
224 let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2))
225 else {
226 return Err("color OBJECT FG BG [PATTERN]".into());
227 };
228 Ok(vec![Command::Color {
229 object: object.clone(),
230 fg: fg.clone(),
231 bg: bg.clone(),
232 pattern: (args.len() > 3).then(|| args[3..].join(" ")),
235 }])
236 }
237 "ignore" => want(args, "ignore which headers?").map(|h| vec![Command::Ignore(h)]),
238 "unignore" => want(args, "unignore which headers?").map(|h| vec![Command::Unignore(h)]),
239 "alternates" => want(args, "alternates PATTERN...").map(|p| vec![Command::Alternates(p)]),
240 "unalternates" => {
241 want(args, "unalternates PATTERN...").map(|p| vec![Command::Unalternates(p)])
242 }
243 "auto_view" => want(args, "auto_view MIMETYPE...").map(|t| vec![Command::AutoView(t)]),
244 "unauto_view" => {
245 want(args, "unauto_view MIMETYPE...").map(|t| vec![Command::UnAutoView(t)])
246 }
247 "alternative_order" => {
248 want(args, "alternative_order MIMETYPE...").map(|t| vec![Command::AlternativeOrder(t)])
249 }
250 "unalternative_order" => want(args, "unalternative_order MIMETYPE...")
251 .map(|t| vec![Command::UnAlternativeOrder(t)]),
252 "my_hdr" => {
253 let rest = trimmed["my_hdr".len()..].trim().trim_matches('"');
256 if !rest.contains(':') || rest.starts_with(':') {
257 return Err("my_hdr \"Name: value\"".into());
258 }
259 Ok(vec![Command::MyHdr(rest.to_string())])
260 }
261 "unmy_hdr" => want(args, "unmy_hdr NAME...").map(|n| vec![Command::UnMyHdr(n)]),
262 "alias" => {
263 let (Some(nick), rest) = (args.first(), &args[1.min(args.len())..]) else {
264 return Err("alias NICK ADDRESS".into());
265 };
266 if rest.is_empty() {
267 return Err("alias NICK ADDRESS".into());
268 }
269 Ok(vec![Command::Alias {
270 nick: nick.clone(),
271 expansion: rest.join(" "),
272 }])
273 }
274 "push" => {
275 if args.is_empty() {
276 return Err("push what?".into());
277 }
278 Ok(vec![Command::Push(args.join(" "))])
279 }
280 "exec" => {
281 let Some(function) = args.first() else {
282 return Err("exec what?".into());
283 };
284 Ok(vec![Command::Exec(function.clone())])
285 }
286 other => Err(format!("unknown command {other:?}")),
287 }
288}
289
290fn want(args: &[String], msg: &str) -> Result<Vec<String>, String> {
291 if args.is_empty() {
292 Err(msg.to_string())
293 } else {
294 Ok(args.to_vec())
295 }
296}
297
298fn one_set((name, value): (String, String)) -> Command {
301 if let Some(base) = name.strip_suffix('?') {
302 return Command::Query(base.to_string());
303 }
304 if let Some(base) = name.strip_prefix("inv") {
305 return Command::Toggle(base.to_string());
306 }
307 if value == "no" {
308 return Command::Unset(name);
309 }
310 Command::Set { name, value }
311}
312
313enum Slot<'a> {
315 Text(&'a mut Option<String>),
316 Flag(&'a mut bool),
317 FlagOpt(&'a mut Option<bool>),
318 Num16(&'a mut u16),
319 NumUsize(&'a mut usize),
320 NumU64(&'a mut u64),
321 NumU64Opt(&'a mut Option<u64>),
322 NumI64Opt(&'a mut Option<i64>),
323}
324
325fn slot<'a>(cfg: &'a mut Config, name: &str) -> Option<Slot<'a>> {
329 use Slot::*;
330 Some(match name {
331 "index_format" => Text(&mut cfg.index.format),
332 "date_format" => Text(&mut cfg.index.date_format),
333 "sort" => Text(&mut cfg.index.sort),
334 "sort_aux" => Text(&mut cfg.index.sort_aux),
335 "pager_format" => Text(&mut cfg.pager.format),
336 "quote_regexp" => Text(&mut cfg.pager.quote_regexp),
337 "status_format" => Text(&mut cfg.ui.status_format),
338 "status_chars" => Text(&mut cfg.ui.status_chars),
339 "ts_status_format" => Text(&mut cfg.ui.title_format),
340 "ts_enabled" => FlagOpt(&mut cfg.ui.set_title),
341 "history_file" => Text(&mut cfg.ui.history_file),
342 "status_on_top" => FlagOpt(&mut cfg.ui.status_on_top),
343 "arrow_cursor" => FlagOpt(&mut cfg.ui.arrow_cursor),
344 "theme" => Text(&mut cfg.ui.theme),
345 "sendmail" => Text(&mut cfg.mail.sendmail),
346 "editor" | "visual" => Text(&mut cfg.mail.editor),
347 "print_command" => Text(&mut cfg.mail.print),
348 "hostname" => Text(&mut cfg.mail.hostname),
349 "sig_on_top" => FlagOpt(&mut cfg.mail.sig_on_top),
350 "user_agent" => FlagOpt(&mut cfg.mail.user_agent),
351 "pipe_sep" => Text(&mut cfg.mail.pipe_sep),
352 "pipe_decode" => FlagOpt(&mut cfg.mail.pipe_decode),
353 "print_decode" => FlagOpt(&mut cfg.mail.print_decode),
354 "pipe_split" => FlagOpt(&mut cfg.mail.pipe_split),
355 "print_split" => FlagOpt(&mut cfg.mail.print_split),
356 "query_command" => Text(&mut cfg.mail.query_command),
357 "trash" => Text(&mut cfg.mail.trash),
358 "record" => Text(&mut cfg.mail.sent),
359 "postponed" => Text(&mut cfg.mail.postponed),
360 "new_mail_command" => Text(&mut cfg.mail.new_mail_command),
361 "forward" => Text(&mut cfg.mail.forward),
362 "realname" => Text(&mut cfg.identity.name),
363 "pgp_sign_as" | "pgp_default_key" => Text(&mut cfg.pgp.sign_key),
364 "crypt_replysign" => Flag(&mut cfg.pgp.reply_sign),
365 "crypt_replyencrypt" => Flag(&mut cfg.pgp.reply_encrypt),
366 "crypt_replysignencrypted" => Flag(&mut cfg.pgp.reply_sign_encrypted),
367 "fast_reply" => Flag(&mut cfg.mail.fast_reply),
368 "askcc" => Flag(&mut cfg.mail.ask_cc),
369 "confirmappend" => Flag(&mut cfg.mail.confirmappend),
370 "save_name" => Flag(&mut cfg.mail.save_name),
371 "force_name" => Flag(&mut cfg.mail.force_name),
372 "quit" => Text(&mut cfg.mail.quit),
373 "postpone" => Text(&mut cfg.mail.postpone),
374 "recall" => Text(&mut cfg.mail.recall),
375 "askbcc" => Flag(&mut cfg.mail.ask_bcc),
376 "attribution" => Text(&mut cfg.mail.attribution),
377 "indent_string" => Text(&mut cfg.mail.indent_string),
378 "forward_format" => Text(&mut cfg.mail.forward_format),
379 "reply_regexp" => Text(&mut cfg.mail.reply_regexp),
380 "simple_search" => Text(&mut cfg.mail.simple_search),
381 "wrap_search" => FlagOpt(&mut cfg.mail.wrap_search),
382 "include" => Text(&mut cfg.mail.include),
383 "forward_quote" => Flag(&mut cfg.mail.forward_quote),
384 "signature" => Text(&mut cfg.mail.signature),
385 "sig_dashes" => FlagOpt(&mut cfg.mail.sig_dashes),
386 "abort_nosubject" => Text(&mut cfg.mail.abort_nosubject),
387 "abort_unmodified" => FlagOpt(&mut cfg.mail.abort_unmodified),
388 "autoedit" => Flag(&mut cfg.mail.autoedit),
389 "beep" => Flag(&mut cfg.ui.beep),
390 "beep_new" => Flag(&mut cfg.ui.beep_new),
391 "wait_key" => FlagOpt(&mut cfg.ui.wait_key),
392 "mark_old" => FlagOpt(&mut cfg.mail.mark_old),
393 "delete_untag" => FlagOpt(&mut cfg.mail.delete_untag),
394 "flag_safe" => Flag(&mut cfg.mail.flag_safe),
395 "maildir_trash" => Flag(&mut cfg.mail.maildir_trash),
396 "mail_check_recent" => FlagOpt(&mut cfg.mail.mail_check_recent),
397 "check_new" => FlagOpt(&mut cfg.mail.check_new),
398 "uncollapse_new" => FlagOpt(&mut cfg.index.uncollapse_new),
399 "menu_scroll" => FlagOpt(&mut cfg.ui.menu_scroll),
400 "menu_context" => NumUsize(&mut cfg.ui.menu_context),
401 "menu_move_off" => FlagOpt(&mut cfg.ui.menu_move_off),
402 "help" => FlagOpt(&mut cfg.ui.help),
403 "error_history" => NumUsize(&mut cfg.ui.error_history),
404 "sort_browser" => Text(&mut cfg.ui.sort_browser),
405 "sort_alias" => Text(&mut cfg.mail.sort_alias),
406 "shell" => Text(&mut cfg.mail.shell),
407 "ispell" => Text(&mut cfg.mail.ispell),
408 "tmpdir" => Text(&mut cfg.mail.tmpdir),
409 "print" => Text(&mut cfg.mail.print_confirm),
410 "reverse_realname" => FlagOpt(&mut cfg.identity.reverse_realname),
411 "tilde" => Flag(&mut cfg.pager.tilde),
412 "pager_stop" => Flag(&mut cfg.pager.pager_stop),
413 "markers" => FlagOpt(&mut cfg.pager.markers),
414 "smart_wrap" => FlagOpt(&mut cfg.pager.smart_wrap),
415 "collapse_unread" => FlagOpt(&mut cfg.index.collapse_unread),
416 "hide_thread_subject" => FlagOpt(&mut cfg.index.hide_thread_subject),
417 "strict_threads" => FlagOpt(&mut cfg.index.strict_threads),
418 "sort_re" => FlagOpt(&mut cfg.index.sort_re),
419 "uncollapse_jump" => Flag(&mut cfg.index.uncollapse_jump),
420 "reverse_name" => Flag(&mut cfg.identity.reverse_name),
421 "metoo" => Flag(&mut cfg.mail.metoo),
422 "text_flowed" => Flag(&mut cfg.mail.text_flowed),
423 "reflow_text" => FlagOpt(&mut cfg.pager.reflow_text),
424 "sidebar_visible" => Flag(&mut cfg.sidebar.visible),
425 "crypt_autosign" | "pgp_autosign" => Flag(&mut cfg.pgp.sign_by_default),
426 "crypt_autoencrypt" | "pgp_autoencrypt" => Flag(&mut cfg.pgp.encrypt_by_default),
427 "edit_headers" => FlagOpt(&mut cfg.mail.edit_headers),
428 "copy" => FlagOpt(&mut cfg.mail.copy),
429 "notmuch" => FlagOpt(&mut cfg.mail.notmuch),
430 "pager_index_lines" => Num16(&mut cfg.pager.index_lines),
431 "sidebar_width" => Num16(&mut cfg.sidebar.width),
432 "pager_context" => NumUsize(&mut cfg.pager.context),
433 "search_context" => NumUsize(&mut cfg.pager.search_context),
434 "mail_check" => NumU64Opt(&mut cfg.mail.poll_seconds),
435 "undo_send" => NumU64(&mut cfg.mail.undo_send),
436 "connect_timeout" => NumU64(&mut cfg.net.connect_timeout),
437 "net_timeout" => NumU64(&mut cfg.net.timeout),
438 "delete" => Text(&mut cfg.mail.delete),
439 "abort_noattach" => Text(&mut cfg.mail.abort_noattach),
440 "abort_noattach_regex" => Text(&mut cfg.mail.attach_keyword),
441 "wrap" => NumI64Opt(&mut cfg.pager.wrap),
442 "use_envelope_from" | "envelope_from" => Flag(&mut cfg.mail.use_envelope_from),
444 "envelope_from_address" => Text(&mut cfg.mail.envelope_from_address),
445 "dsn_notify" => Text(&mut cfg.mail.dsn_notify),
446 "dsn_return" => Text(&mut cfg.mail.dsn_return),
447 "reply_self" => Flag(&mut cfg.mail.reply_self),
448 "fcc_attach" => Text(&mut cfg.mail.fcc_attach),
449 "fcc_clear" => Flag(&mut cfg.mail.fcc_clear),
450 "forward_edit" => Text(&mut cfg.mail.forward_edit),
451 "mime_forward_rest" => FlagOpt(&mut cfg.mail.mime_forward_rest),
452 "hyperlinks" => FlagOpt(&mut cfg.ui.hyperlinks),
453 "markdown" => Flag(&mut cfg.mail.markdown),
454 "url_command" => Text(&mut cfg.ui.url_command),
455 _ => return None,
456 })
457}
458
459const SORTS: [&str; 5] = ["date", "from", "subject", "size", "threads"];
462
463pub fn apply(cfg: &mut Config, cmd: &Command) -> Result<Option<String>, String> {
469 match cmd {
470 Command::Set { name, value } => set(cfg, name, value).map(|()| None),
471 Command::Unset(name) => unset(cfg, name).map(|()| None),
472 Command::Reset(name) => reset(cfg, name).map(|()| None),
473 Command::Uncolor { object, pattern } => {
474 uncolor(cfg, object, pattern.as_deref()).map(|()| None)
475 }
476 Command::Unhook(kind) => unhook(cfg, kind).map(|()| None),
477 Command::Unmailboxes(specs) => {
478 if specs.iter().any(|s| s == "*") {
479 cfg.mail.mailboxes.clear();
480 } else {
481 cfg.mail
482 .mailboxes
483 .retain(|m| !specs.iter().any(|s| same_mailbox(s, m)));
484 }
485 Ok(None)
486 }
487
488 Command::Toggle(name) => toggle(cfg, name).map(|()| None),
489 Command::Query(name) => query(cfg, name).map(Some),
490 Command::Color {
491 object,
492 fg,
493 bg,
494 pattern,
495 } => color(cfg, object, fg, bg, pattern.as_deref()).map(|()| None),
496 Command::Ignore(headers) => {
497 edit_header_list(cfg, headers, true);
498 Ok(None)
499 }
500 Command::Unignore(headers) => {
501 edit_header_list(cfg, headers, false);
502 Ok(None)
503 }
504 Command::Alternates(patterns) => {
505 for p in patterns {
506 if !cfg.mail.alternates.contains(p) {
507 cfg.mail.alternates.push(p.clone());
508 }
509 }
510 Ok(None)
511 }
512 Command::Unalternates(patterns) => {
513 for p in patterns {
514 if p == "*" {
515 cfg.mail.alternates.clear();
516 } else {
517 cfg.mail.alternates.retain(|a| a != p);
518 }
519 }
520 Ok(None)
521 }
522 Command::AutoView(types) => {
523 for t in types {
524 cfg.filters.entry(t.to_lowercase()).or_default();
527 }
528 Ok(None)
529 }
530 Command::UnAutoView(types) => {
531 for t in types {
532 if t == "*" {
533 cfg.filters.clear();
534 } else {
535 cfg.filters.remove(&t.to_lowercase());
536 }
537 }
538 Ok(None)
539 }
540 Command::AlternativeOrder(types) => {
541 for t in types {
542 let t = t.to_lowercase();
543 if !cfg.pager.alternative_order.contains(&t) {
544 cfg.pager.alternative_order.push(t);
545 }
546 }
547 Ok(None)
548 }
549 Command::UnAlternativeOrder(types) => {
550 for t in types {
551 if t == "*" {
552 cfg.pager.alternative_order.clear();
553 } else {
554 let t = t.to_lowercase();
555 cfg.pager.alternative_order.retain(|a| *a != t);
556 }
557 }
558 Ok(None)
559 }
560 Command::MyHdr(entry) => {
561 if let Some((name, _)) = entry.split_once(':') {
564 let name = name.trim().to_string();
565 cfg.mail.my_hdr.retain(|h| !header_named(h, &name));
566 }
567 cfg.mail.my_hdr.push(entry.clone());
568 Ok(None)
569 }
570 Command::UnMyHdr(names) => {
571 for name in names {
572 if name == "*" {
573 cfg.mail.my_hdr.clear();
574 } else {
575 let name = name.trim_end_matches(':');
576 cfg.mail.my_hdr.retain(|h| !header_named(h, name));
577 }
578 }
579 Ok(None)
580 }
581 Command::Bind { .. }
582 | Command::Macro { .. }
583 | Command::Alias { .. }
584 | Command::Unalias(_)
585 | Command::Push(_)
586 | Command::Exec(_) => Ok(None),
587 }
588}
589
590fn header_named(entry: &str, name: &str) -> bool {
592 entry
593 .split_once(':')
594 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
595}
596
597fn unknown(name: &str) -> String {
598 format!("unknown or read-only option {name:?}")
599}
600
601fn set(cfg: &mut Config, name: &str, value: &str) -> Result<(), String> {
602 match name {
605 "from" => {
606 let (display, addr) = split_from(value);
607 if let Some(display) = display {
608 cfg.identity.name = Some(display);
609 }
610 cfg.identity.email = Some(addr);
611 return Ok(());
612 }
613 "mime_forward" => {
614 cfg.mail.forward = Some(if is_yes(value) { "attach" } else { "inline" }.into());
615 return Ok(());
616 }
617 _ => {}
618 }
619 if name == "sort" || name == "sort_aux" {
620 let base = value.strip_prefix("reverse-").unwrap_or(value);
621 let known = if name == "sort" {
622 SORTS.contains(&base)
623 } else {
624 base == "last-date-sent" || base == "date"
625 };
626 if !known {
627 return Err(format!("no such sort order {value:?}"));
628 }
629 }
630 let num = |v: &str| -> Result<i64, String> {
631 v.parse::<i64>()
632 .map_err(|_| format!("{name} wants a number, got {v:?}"))
633 };
634 match slot(cfg, name).ok_or_else(|| unknown(name))? {
635 Slot::Text(field) => *field = Some(value.to_string()),
636 Slot::Flag(field) => *field = is_yes(value),
637 Slot::FlagOpt(field) => *field = Some(is_yes(value)),
638 Slot::Num16(field) => *field = u16::try_from(num(value)?.max(0)).unwrap_or(u16::MAX),
639 Slot::NumUsize(field) => *field = usize::try_from(num(value)?.max(0)).unwrap_or(0),
640 Slot::NumU64(field) => *field = num(value)?.max(0) as u64,
641 Slot::NumU64Opt(field) => *field = Some(num(value)?.max(0) as u64),
642 Slot::NumI64Opt(field) => *field = Some(num(value)?),
643 }
644 Ok(())
645}
646
647fn unset(cfg: &mut Config, name: &str) -> Result<(), String> {
651 if name == "from" {
652 cfg.identity.email = None;
653 return Ok(());
654 }
655 let quad = match name {
658 "fcc_attach" => Some(&mut cfg.mail.fcc_attach),
659 "forward_edit" => Some(&mut cfg.mail.forward_edit),
660 _ => None,
661 };
662 if let Some(field) = quad {
663 *field = Some("no".into());
664 return Ok(());
665 }
666 match slot(cfg, name).ok_or_else(|| unknown(name))? {
667 Slot::Text(field) => *field = None,
668 Slot::Flag(field) => *field = false,
669 Slot::FlagOpt(field) => *field = Some(false),
670 Slot::Num16(field) => *field = 0,
671 Slot::NumUsize(field) => *field = 0,
672 Slot::NumU64(field) => *field = 0,
673 Slot::NumU64Opt(field) => *field = None,
674 Slot::NumI64Opt(field) => *field = None,
675 }
676 Ok(())
677}
678
679fn reset(cfg: &mut Config, name: &str) -> Result<(), String> {
681 if name == "all" {
682 return Err("reset all: reload the config instead".into());
683 }
684 if name == "from" {
685 cfg.identity.email = None;
686 return Ok(());
687 }
688 let mut fresh = Config::default();
689 enum Val {
692 Text(Option<String>),
693 Flag(bool),
694 FlagOpt(Option<bool>),
695 N16(u16),
696 NUsize(usize),
697 NU64(u64),
698 NU64Opt(Option<u64>),
699 NI64Opt(Option<i64>),
700 }
701 let value = match slot(&mut fresh, name).ok_or_else(|| unknown(name))? {
702 Slot::Text(f) => Val::Text(f.clone()),
703 Slot::Flag(f) => Val::Flag(*f),
704 Slot::FlagOpt(f) => Val::FlagOpt(*f),
705 Slot::Num16(f) => Val::N16(*f),
706 Slot::NumUsize(f) => Val::NUsize(*f),
707 Slot::NumU64(f) => Val::NU64(*f),
708 Slot::NumU64Opt(f) => Val::NU64Opt(*f),
709 Slot::NumI64Opt(f) => Val::NI64Opt(*f),
710 };
711 match (slot(cfg, name).ok_or_else(|| unknown(name))?, value) {
712 (Slot::Text(f), Val::Text(v)) => *f = v,
713 (Slot::Flag(f), Val::Flag(v)) => *f = v,
714 (Slot::FlagOpt(f), Val::FlagOpt(v)) => *f = v,
715 (Slot::Num16(f), Val::N16(v)) => *f = v,
716 (Slot::NumUsize(f), Val::NUsize(v)) => *f = v,
717 (Slot::NumU64(f), Val::NU64(v)) => *f = v,
718 (Slot::NumU64Opt(f), Val::NU64Opt(v)) => *f = v,
719 (Slot::NumI64Opt(f), Val::NI64Opt(v)) => *f = v,
720 _ => unreachable!("the same name names the same slot"),
721 }
722 Ok(())
723}
724
725fn uncolor(cfg: &mut Config, object: &str, pattern: Option<&str>) -> Result<(), String> {
729 let mut drop = |keys: &[&str]| {
730 for key in keys {
731 cfg.colors.remove(*key);
732 }
733 };
734 if let Some(n) = object.strip_prefix("quoted")
735 && (n.is_empty() || n.parse::<usize>().is_ok())
736 {
737 let key = if n.is_empty() {
738 "quoted".to_string()
739 } else {
740 format!("quoted{n}")
741 };
742 cfg.colors.remove(&key);
743 return Ok(());
744 }
745 match (object, pattern) {
746 ("status", _) => drop(&["status_fg", "status_bg"]),
747 ("search", _) => drop(&["search_fg", "search_bg"]),
748 ("header" | "hdrdefault", _) => drop(&["header"]),
749 ("error", _) => drop(&["error"]),
750 ("index", Some("*")) => {
751 drop(&["deleted", "flagged", "tagged"]);
752 cfg.color_index.clear();
753 }
754 ("index", Some("~D")) => drop(&["deleted"]),
755 ("index", Some("~F")) => drop(&["flagged"]),
756 ("index", Some("~T")) => drop(&["tagged"]),
757 ("index", Some(pattern)) => cfg.color_index.retain(|r| r.pattern != pattern),
758 ("body", Some("*")) => cfg.color_body.clear(),
759 ("body", Some(pattern)) => cfg.color_body.retain(|r| r.pattern != pattern),
760 ("index" | "body", None) => return Err(format!("uncolor {object} needs a pattern or *")),
761 _ => return Err(format!("no rmut color slot for {object:?}")),
762 }
763 Ok(())
764}
765
766fn unhook(cfg: &mut Config, kind: &str) -> Result<(), String> {
768 match kind {
769 "*" => {
770 cfg.folder_hooks.clear();
771 cfg.message_hooks.clear();
772 cfg.reply_hooks.clear();
773 cfg.fcc_hooks.clear();
774 cfg.crypt_hooks.clear();
775 }
776 "folder-hook" => cfg.folder_hooks.clear(),
777 "message-hook" => cfg.message_hooks.clear(),
778 "reply-hook" => cfg.reply_hooks.clear(),
779 "fcc-hook" => cfg.fcc_hooks.clear(),
780 "crypt-hook" => cfg.crypt_hooks.clear(),
781 other => {
782 return Err(format!(
783 "unhook: unknown hook type: {other} (folder-hook, message-hook, reply-hook, fcc-hook, crypt-hook, *)"
784 ));
785 }
786 }
787 Ok(())
788}
789
790fn same_mailbox(a: &str, b: &str) -> bool {
793 a.trim_end_matches('/') == b.trim_end_matches('/')
794}
795
796fn toggle(cfg: &mut Config, name: &str) -> Result<(), String> {
797 match slot(cfg, name).ok_or_else(|| unknown(name))? {
798 Slot::Flag(field) => *field = !*field,
799 Slot::FlagOpt(field) => *field = Some(!field.unwrap_or(false)),
800 _ => return Err(format!("{name} is not a boolean")),
801 }
802 Ok(())
803}
804
805fn query(cfg: &mut Config, name: &str) -> Result<String, String> {
806 if name == "from" {
807 let value = cfg.identity.email.clone().unwrap_or_default();
808 return Ok(format!("from=\"{value}\""));
809 }
810 let text = match slot(cfg, name).ok_or_else(|| unknown(name))? {
811 Slot::Text(field) => format!("{name}=\"{}\"", field.clone().unwrap_or_default()),
812 Slot::Flag(field) => flag_text(name, *field),
813 Slot::FlagOpt(field) => flag_text(name, field.unwrap_or(false)),
814 Slot::Num16(field) => format!("{name}={field}"),
815 Slot::NumUsize(field) => format!("{name}={field}"),
816 Slot::NumU64(field) => format!("{name}={field}"),
817 Slot::NumU64Opt(field) => match field {
818 Some(n) => format!("{name}={n}"),
819 None => format!("{name} is unset"),
820 },
821 Slot::NumI64Opt(field) => match field {
822 Some(n) => format!("{name}={n}"),
823 None => format!("{name} is unset"),
824 },
825 };
826 Ok(text)
827}
828
829fn flag_text(name: &str, on: bool) -> String {
830 if on {
831 name.to_string()
832 } else {
833 format!("no{name}")
834 }
835}
836
837fn edit_header_list(cfg: &mut Config, headers: &[String], ignoring: bool) {
841 let clean: Vec<String> = headers
842 .iter()
843 .map(|h| h.trim_end_matches(':').to_lowercase())
844 .collect();
845 let (add, remove) = if ignoring {
846 (&mut cfg.pager.ignore, &mut cfg.pager.unignore)
847 } else {
848 (&mut cfg.pager.unignore, &mut cfg.pager.ignore)
849 };
850 let list = add.get_or_insert_with(Vec::new);
851 for name in &clean {
852 if !list.contains(name) {
853 list.push(name.clone());
854 }
855 }
856 if let Some(list) = remove {
857 list.retain(|n| !clean.contains(n));
858 }
859}
860
861fn color(
865 cfg: &mut Config,
866 object: &str,
867 fg: &str,
868 bg: &str,
869 pattern: Option<&str>,
870) -> Result<(), String> {
871 let fg = convert_color(fg);
872 let bg = convert_color(bg);
873 let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
876 bg.clone()
877 } else {
878 fg.clone()
879 };
880 if let Some(n) = object.strip_prefix("quoted")
881 && let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
882 {
883 let key = if depth == 0 {
884 "quoted".to_string()
885 } else {
886 format!("quoted{depth}")
887 };
888 cfg.colors.insert(key, vivid);
889 return Ok(());
890 }
891 let mut named = |key: &str, value: String| {
892 cfg.colors.insert(key.to_string(), value);
893 };
894 match (object, pattern) {
895 ("status", _) => {
896 named("status_fg", fg);
897 named("status_bg", bg);
898 }
899 ("search", _) => {
900 if fg != "default" {
901 named("search_fg", fg);
902 }
903 if bg != "default" {
904 named("search_bg", bg);
905 }
906 }
907 ("header" | "hdrdefault", _) => named("header", fg),
908 ("error", _) => named("error", vivid),
909 ("index", Some("~D")) if bg == "default" => named("deleted", vivid),
912 ("index", Some("~F")) if bg == "default" => named("flagged", vivid),
913 ("index", Some("~T")) if bg == "default" => named("tagged", vivid),
914 ("index", Some(pattern)) => {
915 crate::pattern::parse(pattern).map_err(|err| format!("bad pattern: {err}"))?;
916 cfg.color_index.push(rule(pattern, fg, bg));
917 }
918 ("body", Some(pattern)) => {
919 regex_lite::Regex::new(pattern).map_err(|err| format!("bad regex: {err}"))?;
920 cfg.color_body.push(rule(pattern, fg, bg));
921 }
922 ("index" | "body", None) => return Err(format!("color {object} needs a pattern")),
923 _ => return Err(format!("no rmut color slot for {object:?}")),
924 }
925 Ok(())
926}
927
928fn rule(pattern: &str, fg: String, bg: String) -> ColorRule {
929 ColorRule {
930 pattern: pattern.to_string(),
931 fg: (fg != "default").then_some(fg),
932 bg: (bg != "default").then_some(bg),
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 fn one(line: &str) -> Command {
941 let mut cmds = parse(line).expect("parses");
942 assert_eq!(cmds.len(), 1, "{line:?} yielded {cmds:?}");
943 cmds.pop().unwrap()
944 }
945
946 #[test]
947 fn alternates_and_my_hdr_at_the_prompt() {
948 let mut cfg = Config::default();
949 for line in [
950 "alternates jane@old\\.example\\.com typo@x",
951 "unalternates typo@x",
952 "my_hdr Organization: Acme",
953 "my_hdr X-Mailer: rmut",
954 "my_hdr Organization: Acme Ltd",
955 "unmy_hdr X-Mailer",
956 "set metoo",
957 ] {
958 for cmd in parse(line).expect(line) {
959 apply(&mut cfg, &cmd).expect(line);
960 }
961 }
962 assert_eq!(cfg.mail.alternates, ["jane@old\\.example\\.com"]);
963 assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
964 assert!(cfg.mail.metoo);
965 for cmd in parse("unmy_hdr *").unwrap() {
966 apply(&mut cfg, &cmd).unwrap();
967 }
968 assert!(cfg.mail.my_hdr.is_empty());
969 assert!(parse("my_hdr Organization").is_err());
970 assert!(parse("alternates").is_err());
971 }
972
973 #[test]
974 fn envelope_options_at_the_prompt() {
975 let mut cfg = Config::default();
976 for line in [
977 "set envelope_from",
978 "set envelope_from_address=\"Bounces <bounces@example.com>\"",
979 "set dsn_notify=failure,delay dsn_return=hdrs",
980 "set reply_self fcc_clear",
981 "unset fcc_attach",
982 "unset forward_edit",
983 "set mime_forward_rest=no",
984 ] {
985 for cmd in parse(line).expect(line) {
986 apply(&mut cfg, &cmd).expect(line);
987 }
988 }
989 let mail = &cfg.mail;
990 assert!(mail.use_envelope_from && mail.reply_self && mail.fcc_clear);
991 assert_eq!(mail.fcc_attach.as_deref(), Some("no"));
992 assert_eq!(mail.forward_edit.as_deref(), Some("no"));
993 assert_eq!(mail.mime_forward_rest, Some(false));
994 let envelope = mail.envelope("jane@example.com");
995 assert_eq!(envelope.sender.as_deref(), Some("bounces@example.com"));
996 assert_eq!(envelope.notify.as_deref(), Some("failure,delay"));
997 assert_eq!(envelope.ret.as_deref(), Some("hdrs"));
998 cfg.mail.envelope_from_address = None;
1001 let from = cfg.mail.envelope("jane@example.com").sender;
1002 assert_eq!(from.as_deref(), Some("jane@example.com"));
1003 cfg.mail.use_envelope_from = false;
1004 assert_eq!(cfg.mail.envelope("jane@example.com").sender, None);
1005 }
1006
1007 #[test]
1008 fn auto_view_and_alternative_order_at_the_prompt() {
1009 let mut cfg = Config::default();
1010 for line in [
1011 "auto_view text/html Application/Zip",
1012 "auto_view text/calendar",
1013 "unauto_view text/calendar",
1014 "alternative_order text/plain TEXT/HTML",
1015 "alternative_order text/plain",
1016 "unalternative_order text/html",
1017 ] {
1018 for cmd in parse(line).expect(line) {
1019 apply(&mut cfg, &cmd).expect(line);
1020 }
1021 }
1022 let mut types: Vec<&str> = cfg.filters.keys().map(String::as_str).collect();
1024 types.sort_unstable();
1025 assert_eq!(types, ["application/zip", "text/html"]);
1026 assert!(cfg.filters.values().all(String::is_empty));
1027 cfg.filters
1029 .insert("text/html".into(), "w3m -dump -T text/html".into());
1030 for cmd in parse("auto_view text/html").unwrap() {
1031 apply(&mut cfg, &cmd).unwrap();
1032 }
1033 assert_eq!(
1034 cfg.filters.get("text/html").map(String::as_str),
1035 Some("w3m -dump -T text/html")
1036 );
1037 assert_eq!(cfg.pager.alternative_order, ["text/plain"]);
1039 for cmd in parse("unauto_view *").unwrap() {
1040 apply(&mut cfg, &cmd).unwrap();
1041 }
1042 assert!(cfg.filters.is_empty());
1043 assert!(parse("auto_view").is_err());
1044 assert!(parse("alternative_order").is_err());
1045 }
1046
1047 #[test]
1048 fn set_forms() {
1049 assert_eq!(
1050 one("set index_format=\"%s %f\""),
1051 Command::Set {
1052 name: "index_format".into(),
1053 value: "%s %f".into()
1054 }
1055 );
1056 assert_eq!(one("set nobeep"), Command::Unset("beep".into()));
1057 assert_eq!(
1058 one("set beep"),
1059 Command::Set {
1060 name: "beep".into(),
1061 value: "yes".into()
1062 }
1063 );
1064 assert_eq!(one("set invbeep"), Command::Toggle("beep".into()));
1065 assert_eq!(one("set beep?"), Command::Query("beep".into()));
1066 assert_eq!(one("toggle tilde"), Command::Toggle("tilde".into()));
1067 assert_eq!(one("unset tilde"), Command::Unset("tilde".into()));
1068 assert_eq!(parse("set a=1 b=2").unwrap().len(), 2);
1069 assert_eq!(parse("").unwrap(), vec![]);
1070 assert_eq!(parse("# comment").unwrap(), vec![]);
1071 }
1072
1073 #[test]
1074 fn unknown_command_and_option_report() {
1075 assert!(parse("frobnicate x").is_err());
1076 let mut cfg = Config::default();
1077 let err = apply(&mut cfg, &one("set nosuchthing=1")).unwrap_err();
1078 assert!(err.contains("nosuchthing"), "{err}");
1079 }
1080
1081 #[test]
1082 fn set_applies_to_config() {
1083 let mut cfg = Config::default();
1084 apply(&mut cfg, &one("set pager_index_lines=5")).unwrap();
1085 assert_eq!(cfg.pager.index_lines, 5);
1086 apply(&mut cfg, &one("set wrap=-8")).unwrap();
1087 assert_eq!(cfg.pager.wrap, Some(-8));
1088 apply(&mut cfg, &one("set tilde")).unwrap();
1089 assert!(cfg.pager.tilde);
1090 apply(&mut cfg, &one("toggle tilde")).unwrap();
1091 assert!(!cfg.pager.tilde);
1092 apply(&mut cfg, &one("set from=\"Jane Doe <jane@x>\"")).unwrap();
1093 assert_eq!(cfg.identity.email.as_deref(), Some("jane@x"));
1094 assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
1095 assert!(apply(&mut cfg, &one("set pager_index_lines=x")).is_err());
1097 assert!(apply(&mut cfg, &one("set sort=nonsense")).is_err());
1098 apply(&mut cfg, &one("set sort=reverse-date")).unwrap();
1099 assert_eq!(cfg.index.sort.as_deref(), Some("reverse-date"));
1100 }
1101
1102 #[test]
1103 fn query_reports_current_value() {
1104 let mut cfg = Config::default();
1105 cfg.index.format = Some("%s".into());
1106 let out = apply(&mut cfg, &one("set index_format?")).unwrap();
1107 assert_eq!(out.as_deref(), Some("index_format=\"%s\""));
1108 let out = apply(&mut cfg, &one("set beep?")).unwrap();
1109 assert_eq!(out.as_deref(), Some("beep"));
1110 apply(&mut cfg, &one("unset beep")).unwrap();
1111 let out = apply(&mut cfg, &one("set beep?")).unwrap();
1112 assert_eq!(out.as_deref(), Some("nobeep"));
1113 }
1114
1115 #[test]
1116 fn ignore_lists_move_names_between_them() {
1117 let mut cfg = Config::default();
1118 apply(&mut cfg, &one("ignore x-spam: received")).unwrap();
1119 assert_eq!(
1120 cfg.pager.ignore.as_deref(),
1121 Some(["x-spam".to_string(), "received".to_string()].as_slice())
1122 );
1123 apply(&mut cfg, &one("unignore received")).unwrap();
1124 assert_eq!(
1125 cfg.pager.ignore.as_deref(),
1126 Some(["x-spam".to_string()].as_slice())
1127 );
1128 assert_eq!(
1129 cfg.pager.unignore.as_deref(),
1130 Some(["received".to_string()].as_slice())
1131 );
1132 }
1133
1134 #[test]
1135 fn colors_land_in_their_slots() {
1136 let mut cfg = Config::default();
1137 apply(&mut cfg, &one("color status brightwhite blue")).unwrap();
1138 assert_eq!(
1139 cfg.colors.get("status_fg").map(String::as_str),
1140 Some("lightwhite")
1141 );
1142 assert_eq!(
1143 cfg.colors.get("status_bg").map(String::as_str),
1144 Some("blue")
1145 );
1146 apply(&mut cfg, &one("color quoted2 green default")).unwrap();
1147 assert_eq!(cfg.colors.get("quoted2").map(String::as_str), Some("green"));
1148 apply(&mut cfg, &one("color index red default ~F")).unwrap();
1149 assert_eq!(cfg.colors.get("flagged").map(String::as_str), Some("red"));
1150 apply(&mut cfg, &one("color index red default ~f jane")).unwrap();
1151 assert_eq!(cfg.color_index.len(), 1);
1152 apply(&mut cfg, &one("color body yellow default TODO")).unwrap();
1153 assert_eq!(cfg.color_body.len(), 1);
1154 assert!(apply(&mut cfg, &one("color index red default ~q")).is_err());
1156 assert!(apply(&mut cfg, &one("color body red default [")).is_err());
1157 assert!(apply(&mut cfg, &one("color nosuchslot red default")).is_err());
1158 }
1159
1160 #[test]
1161 fn bind_macro_and_push_parse_but_stay_for_the_caller() {
1162 assert_eq!(
1163 one("bind index \\Cd delete-message"),
1164 Command::Bind {
1165 menu: Menu::Index,
1166 key: "\\Cd".into(),
1167 function: "delete-message".into()
1168 }
1169 );
1170 assert_eq!(parse("bind index,pager d delete-message").unwrap().len(), 2);
1171 assert!(parse("bind nosuchmenu d delete").is_err());
1172 assert_eq!(
1173 one("macro index L \"l~f jane<enter>\""),
1174 Command::Macro {
1175 menu: Menu::Index,
1176 key: "L".into(),
1177 seq: "l~f jane<enter>".into()
1178 }
1179 );
1180 assert_eq!(one("push \"<enter>\""), Command::Push("<enter>".into()));
1181 assert_eq!(one("exec sync"), Command::Exec("sync".into()));
1182 assert_eq!(
1183 one("alias jane Jane Doe <jane@x>"),
1184 Command::Alias {
1185 nick: "jane".into(),
1186 expansion: "Jane Doe <jane@x>".into()
1187 }
1188 );
1189 let mut cfg = Config::default();
1191 assert_eq!(apply(&mut cfg, &one("exec sync")).unwrap(), None);
1192 }
1193
1194 #[test]
1195 fn reset_restores_the_default_where_unset_zeroes() {
1196 let mut cfg = Config::default();
1197 for line in ["set pager_context=7", "set nobeep", "set editor=vim"] {
1198 apply(&mut cfg, &one(line)).unwrap();
1199 }
1200 assert_eq!(cfg.pager.context, 7);
1201 for cmd in parse("reset pager_context beep editor").unwrap() {
1202 apply(&mut cfg, &cmd).unwrap();
1203 }
1204 let fresh = Config::default();
1205 assert_eq!(cfg.pager.context, fresh.pager.context);
1206 assert_eq!(cfg.ui.beep, fresh.ui.beep, "back on, not off");
1207 assert_eq!(cfg.mail.editor, fresh.mail.editor);
1208 assert!(apply(&mut cfg, &one("reset all")).is_err());
1209 assert!(parse("reset").is_err(), "reset wants a name");
1210 }
1211
1212 #[test]
1213 fn uncolor_takes_rules_and_slots_back_out() {
1214 let mut cfg = Config::default();
1215 for line in [
1216 "color index brightyellow default ~F",
1217 "color index red default ~f jane",
1218 "color index blue default ~s urgent",
1219 "color body green default ^Signed",
1220 "color status white blue",
1221 "color quoted1 cyan default",
1222 ] {
1223 apply(&mut cfg, &one(line)).unwrap();
1224 }
1225 assert_eq!(cfg.color_index.len(), 2, "~F went to the flagged slot");
1226 apply(&mut cfg, &one("uncolor index ~f jane")).unwrap();
1227 assert_eq!(cfg.color_index.len(), 1);
1228 assert_eq!(cfg.color_index[0].pattern, "~s urgent");
1229 apply(&mut cfg, &one("uncolor index ~F")).unwrap();
1230 assert!(!cfg.colors.contains_key("flagged"));
1231 apply(&mut cfg, &one("uncolor index *")).unwrap();
1232 assert!(cfg.color_index.is_empty());
1233 apply(&mut cfg, &one("unmono body ^Signed")).unwrap();
1234 assert!(cfg.color_body.is_empty());
1235 apply(&mut cfg, &one("uncolor status")).unwrap();
1236 assert!(!cfg.colors.contains_key("status_fg"));
1237 apply(&mut cfg, &one("uncolor quoted1")).unwrap();
1238 assert!(!cfg.colors.contains_key("quoted1"));
1239 assert!(
1240 apply(&mut cfg, &one("uncolor index")).is_err(),
1241 "a pattern or *"
1242 );
1243 }
1244
1245 #[test]
1246 fn mono_is_a_color_line_carrying_an_attribute() {
1247 let mut cfg = Config::default();
1248 apply(&mut cfg, &one("mono index bold ~N")).unwrap();
1249 assert_eq!(cfg.color_index[0].fg.as_deref(), Some("bold"));
1250 assert_eq!(cfg.color_index[0].bg, None);
1251 assert!(parse("mono index shiny ~N").is_err(), "no such attribute");
1252 }
1253
1254 #[test]
1255 fn unhook_and_unmailboxes_clear_by_name_or_star() {
1256 let mut cfg = Config::default();
1257 cfg.folder_hooks.push(Default::default());
1258 cfg.message_hooks.push(Default::default());
1259 cfg.mail.mailboxes = vec!["~/Mail/a".into(), "~/Mail/b/".into(), "imap:w/INBOX".into()];
1260 apply(&mut cfg, &one("unhook folder-hook")).unwrap();
1261 assert!(cfg.folder_hooks.is_empty() && !cfg.message_hooks.is_empty());
1262 assert!(
1263 apply(&mut cfg, &one("unhook send-hook")).is_err(),
1264 "not a type rmut has"
1265 );
1266 apply(&mut cfg, &one("unhook *")).unwrap();
1267 assert!(cfg.message_hooks.is_empty());
1268 apply(&mut cfg, &one("unmailboxes ~/Mail/b")).unwrap();
1269 assert_eq!(
1270 cfg.mail.mailboxes,
1271 ["~/Mail/a", "imap:w/INBOX"],
1272 "a slash is no difference"
1273 );
1274 apply(&mut cfg, &one("unmailboxes *")).unwrap();
1275 assert!(cfg.mail.mailboxes.is_empty());
1276 }
1277}