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 Alternates(Vec<String>),
76 Unalternates(Vec<String>),
77 AutoView(Vec<String>),
81 UnAutoView(Vec<String>),
82 AlternativeOrder(Vec<String>),
85 UnAlternativeOrder(Vec<String>),
86 MyHdr(String),
88 UnMyHdr(Vec<String>),
90 Alias {
91 nick: String,
92 expansion: String,
93 },
94 Push(String),
96 Exec(String),
98}
99
100pub fn parse(line: &str) -> Result<Vec<Command>, String> {
104 let trimmed = line.trim();
105 if trimmed.is_empty() || trimmed.starts_with('#') {
106 return Ok(Vec::new());
107 }
108 let tokens = tokenize(trimmed);
109 let Some(cmd) = tokens.first() else {
110 return Ok(Vec::new());
111 };
112 let args = &tokens[1..];
113 match cmd.as_str() {
114 "set" => {
115 if args.is_empty() {
116 return Err("set what?".into());
117 }
118 Ok(assignments(args).into_iter().map(one_set).collect())
119 }
120 "unset" | "reset" => want(args, "unset what?").map(|names| {
121 names
122 .iter()
123 .map(|n| Command::Unset(n.to_string()))
124 .collect()
125 }),
126 "toggle" => want(args, "toggle what?").map(|names| {
127 names
128 .iter()
129 .map(|n| Command::Toggle(n.to_string()))
130 .collect()
131 }),
132 "bind" | "macro" => {
133 let (Some(menus), Some(key)) = (args.first(), args.get(1)) else {
134 return Err(format!("{cmd} MENU KEY ..."));
135 };
136 let Some(rest) = args.get(2) else {
137 return Err(if cmd == "bind" {
138 "bind MENU KEY FUNCTION".into()
139 } else {
140 "macro MENU KEY SEQUENCE".into()
141 });
142 };
143 let mut out = Vec::new();
144 for name in menus.split(',') {
145 let Some(menu) = Menu::parse(name) else {
146 return Err(format!("unknown menu {name:?}"));
147 };
148 out.push(if cmd == "bind" {
149 Command::Bind {
150 menu,
151 key: key.clone(),
152 function: rest.clone(),
153 }
154 } else {
155 Command::Macro {
156 menu,
157 key: key.clone(),
158 seq: rest.clone(),
159 }
160 });
161 }
162 Ok(out)
163 }
164 "color" => {
165 let (Some(object), Some(fg), Some(bg)) = (args.first(), args.get(1), args.get(2))
166 else {
167 return Err("color OBJECT FG BG [PATTERN]".into());
168 };
169 Ok(vec![Command::Color {
170 object: object.clone(),
171 fg: fg.clone(),
172 bg: bg.clone(),
173 pattern: (args.len() > 3).then(|| args[3..].join(" ")),
176 }])
177 }
178 "ignore" => want(args, "ignore which headers?").map(|h| vec![Command::Ignore(h)]),
179 "unignore" => want(args, "unignore which headers?").map(|h| vec![Command::Unignore(h)]),
180 "alternates" => want(args, "alternates PATTERN...").map(|p| vec![Command::Alternates(p)]),
181 "unalternates" => {
182 want(args, "unalternates PATTERN...").map(|p| vec![Command::Unalternates(p)])
183 }
184 "auto_view" => want(args, "auto_view MIMETYPE...").map(|t| vec![Command::AutoView(t)]),
185 "unauto_view" => {
186 want(args, "unauto_view MIMETYPE...").map(|t| vec![Command::UnAutoView(t)])
187 }
188 "alternative_order" => {
189 want(args, "alternative_order MIMETYPE...").map(|t| vec![Command::AlternativeOrder(t)])
190 }
191 "unalternative_order" => want(args, "unalternative_order MIMETYPE...")
192 .map(|t| vec![Command::UnAlternativeOrder(t)]),
193 "my_hdr" => {
194 let rest = trimmed["my_hdr".len()..].trim().trim_matches('"');
197 if !rest.contains(':') || rest.starts_with(':') {
198 return Err("my_hdr \"Name: value\"".into());
199 }
200 Ok(vec![Command::MyHdr(rest.to_string())])
201 }
202 "unmy_hdr" => want(args, "unmy_hdr NAME...").map(|n| vec![Command::UnMyHdr(n)]),
203 "alias" => {
204 let (Some(nick), rest) = (args.first(), &args[1.min(args.len())..]) else {
205 return Err("alias NICK ADDRESS".into());
206 };
207 if rest.is_empty() {
208 return Err("alias NICK ADDRESS".into());
209 }
210 Ok(vec![Command::Alias {
211 nick: nick.clone(),
212 expansion: rest.join(" "),
213 }])
214 }
215 "push" => {
216 if args.is_empty() {
217 return Err("push what?".into());
218 }
219 Ok(vec![Command::Push(args.join(" "))])
220 }
221 "exec" => {
222 let Some(function) = args.first() else {
223 return Err("exec what?".into());
224 };
225 Ok(vec![Command::Exec(function.clone())])
226 }
227 other => Err(format!("unknown command {other:?}")),
228 }
229}
230
231fn want(args: &[String], msg: &str) -> Result<Vec<String>, String> {
232 if args.is_empty() {
233 Err(msg.to_string())
234 } else {
235 Ok(args.to_vec())
236 }
237}
238
239fn one_set((name, value): (String, String)) -> Command {
242 if let Some(base) = name.strip_suffix('?') {
243 return Command::Query(base.to_string());
244 }
245 if let Some(base) = name.strip_prefix("inv") {
246 return Command::Toggle(base.to_string());
247 }
248 if value == "no" {
249 return Command::Unset(name);
250 }
251 Command::Set { name, value }
252}
253
254enum Slot<'a> {
256 Text(&'a mut Option<String>),
257 Flag(&'a mut bool),
258 FlagOpt(&'a mut Option<bool>),
259 Num16(&'a mut u16),
260 NumUsize(&'a mut usize),
261 NumU64(&'a mut u64),
262 NumU64Opt(&'a mut Option<u64>),
263 NumI64Opt(&'a mut Option<i64>),
264}
265
266fn slot<'a>(cfg: &'a mut Config, name: &str) -> Option<Slot<'a>> {
270 use Slot::*;
271 Some(match name {
272 "index_format" => Text(&mut cfg.index.format),
273 "date_format" => Text(&mut cfg.index.date_format),
274 "sort" => Text(&mut cfg.index.sort),
275 "sort_aux" => Text(&mut cfg.index.sort_aux),
276 "pager_format" => Text(&mut cfg.pager.format),
277 "quote_regexp" => Text(&mut cfg.pager.quote_regexp),
278 "status_format" => Text(&mut cfg.ui.status_format),
279 "status_chars" => Text(&mut cfg.ui.status_chars),
280 "ts_status_format" => Text(&mut cfg.ui.title_format),
281 "ts_enabled" => FlagOpt(&mut cfg.ui.set_title),
282 "history_file" => Text(&mut cfg.ui.history_file),
283 "status_on_top" => FlagOpt(&mut cfg.ui.status_on_top),
284 "arrow_cursor" => FlagOpt(&mut cfg.ui.arrow_cursor),
285 "theme" => Text(&mut cfg.ui.theme),
286 "sendmail" => Text(&mut cfg.mail.sendmail),
287 "editor" | "visual" => Text(&mut cfg.mail.editor),
288 "print_command" => Text(&mut cfg.mail.print),
289 "hostname" => Text(&mut cfg.mail.hostname),
290 "sig_on_top" => FlagOpt(&mut cfg.mail.sig_on_top),
291 "user_agent" => FlagOpt(&mut cfg.mail.user_agent),
292 "pipe_sep" => Text(&mut cfg.mail.pipe_sep),
293 "pipe_decode" => FlagOpt(&mut cfg.mail.pipe_decode),
294 "print_decode" => FlagOpt(&mut cfg.mail.print_decode),
295 "pipe_split" => FlagOpt(&mut cfg.mail.pipe_split),
296 "print_split" => FlagOpt(&mut cfg.mail.print_split),
297 "query_command" => Text(&mut cfg.mail.query_command),
298 "trash" => Text(&mut cfg.mail.trash),
299 "record" => Text(&mut cfg.mail.sent),
300 "postponed" => Text(&mut cfg.mail.postponed),
301 "new_mail_command" => Text(&mut cfg.mail.new_mail_command),
302 "forward" => Text(&mut cfg.mail.forward),
303 "realname" => Text(&mut cfg.identity.name),
304 "pgp_sign_as" | "pgp_default_key" => Text(&mut cfg.pgp.sign_key),
305 "crypt_replysign" => Flag(&mut cfg.pgp.reply_sign),
306 "crypt_replyencrypt" => Flag(&mut cfg.pgp.reply_encrypt),
307 "crypt_replysignencrypted" => Flag(&mut cfg.pgp.reply_sign_encrypted),
308 "fast_reply" => Flag(&mut cfg.mail.fast_reply),
309 "askcc" => Flag(&mut cfg.mail.ask_cc),
310 "confirmappend" => Flag(&mut cfg.mail.confirmappend),
311 "save_name" => Flag(&mut cfg.mail.save_name),
312 "force_name" => Flag(&mut cfg.mail.force_name),
313 "quit" => Text(&mut cfg.mail.quit),
314 "postpone" => Text(&mut cfg.mail.postpone),
315 "recall" => Text(&mut cfg.mail.recall),
316 "askbcc" => Flag(&mut cfg.mail.ask_bcc),
317 "attribution" => Text(&mut cfg.mail.attribution),
318 "indent_string" => Text(&mut cfg.mail.indent_string),
319 "forward_format" => Text(&mut cfg.mail.forward_format),
320 "reply_regexp" => Text(&mut cfg.mail.reply_regexp),
321 "simple_search" => Text(&mut cfg.mail.simple_search),
322 "wrap_search" => FlagOpt(&mut cfg.mail.wrap_search),
323 "include" => Text(&mut cfg.mail.include),
324 "forward_quote" => Flag(&mut cfg.mail.forward_quote),
325 "signature" => Text(&mut cfg.mail.signature),
326 "sig_dashes" => FlagOpt(&mut cfg.mail.sig_dashes),
327 "abort_nosubject" => Text(&mut cfg.mail.abort_nosubject),
328 "abort_unmodified" => FlagOpt(&mut cfg.mail.abort_unmodified),
329 "autoedit" => Flag(&mut cfg.mail.autoedit),
330 "beep" => Flag(&mut cfg.ui.beep),
331 "beep_new" => Flag(&mut cfg.ui.beep_new),
332 "wait_key" => FlagOpt(&mut cfg.ui.wait_key),
333 "mark_old" => FlagOpt(&mut cfg.mail.mark_old),
334 "print" => Text(&mut cfg.mail.print_confirm),
335 "reverse_realname" => FlagOpt(&mut cfg.identity.reverse_realname),
336 "tilde" => Flag(&mut cfg.pager.tilde),
337 "pager_stop" => Flag(&mut cfg.pager.pager_stop),
338 "markers" => FlagOpt(&mut cfg.pager.markers),
339 "smart_wrap" => FlagOpt(&mut cfg.pager.smart_wrap),
340 "collapse_unread" => FlagOpt(&mut cfg.index.collapse_unread),
341 "hide_thread_subject" => FlagOpt(&mut cfg.index.hide_thread_subject),
342 "strict_threads" => FlagOpt(&mut cfg.index.strict_threads),
343 "sort_re" => FlagOpt(&mut cfg.index.sort_re),
344 "uncollapse_jump" => Flag(&mut cfg.index.uncollapse_jump),
345 "reverse_name" => Flag(&mut cfg.identity.reverse_name),
346 "metoo" => Flag(&mut cfg.mail.metoo),
347 "text_flowed" => Flag(&mut cfg.mail.text_flowed),
348 "reflow_text" => FlagOpt(&mut cfg.pager.reflow_text),
349 "sidebar_visible" => Flag(&mut cfg.sidebar.visible),
350 "crypt_autosign" | "pgp_autosign" => Flag(&mut cfg.pgp.sign_by_default),
351 "crypt_autoencrypt" | "pgp_autoencrypt" => Flag(&mut cfg.pgp.encrypt_by_default),
352 "edit_headers" => FlagOpt(&mut cfg.mail.edit_headers),
353 "copy" => FlagOpt(&mut cfg.mail.copy),
354 "notmuch" => FlagOpt(&mut cfg.mail.notmuch),
355 "pager_index_lines" => Num16(&mut cfg.pager.index_lines),
356 "sidebar_width" => Num16(&mut cfg.sidebar.width),
357 "pager_context" => NumUsize(&mut cfg.pager.context),
358 "search_context" => NumUsize(&mut cfg.pager.search_context),
359 "mail_check" => NumU64Opt(&mut cfg.mail.poll_seconds),
360 "undo_send" => NumU64(&mut cfg.mail.undo_send),
361 "connect_timeout" => NumU64(&mut cfg.net.connect_timeout),
362 "net_timeout" => NumU64(&mut cfg.net.timeout),
363 "delete" => Text(&mut cfg.mail.delete),
364 "abort_noattach" => Text(&mut cfg.mail.abort_noattach),
365 "abort_noattach_regex" => Text(&mut cfg.mail.attach_keyword),
366 "wrap" => NumI64Opt(&mut cfg.pager.wrap),
367 _ => return None,
368 })
369}
370
371const SORTS: [&str; 5] = ["date", "from", "subject", "size", "threads"];
374
375pub fn apply(cfg: &mut Config, cmd: &Command) -> Result<Option<String>, String> {
381 match cmd {
382 Command::Set { name, value } => set(cfg, name, value).map(|()| None),
383 Command::Unset(name) => unset(cfg, name).map(|()| None),
384 Command::Toggle(name) => toggle(cfg, name).map(|()| None),
385 Command::Query(name) => query(cfg, name).map(Some),
386 Command::Color {
387 object,
388 fg,
389 bg,
390 pattern,
391 } => color(cfg, object, fg, bg, pattern.as_deref()).map(|()| None),
392 Command::Ignore(headers) => {
393 edit_header_list(cfg, headers, true);
394 Ok(None)
395 }
396 Command::Unignore(headers) => {
397 edit_header_list(cfg, headers, false);
398 Ok(None)
399 }
400 Command::Alternates(patterns) => {
401 for p in patterns {
402 if !cfg.mail.alternates.contains(p) {
403 cfg.mail.alternates.push(p.clone());
404 }
405 }
406 Ok(None)
407 }
408 Command::Unalternates(patterns) => {
409 for p in patterns {
410 if p == "*" {
411 cfg.mail.alternates.clear();
412 } else {
413 cfg.mail.alternates.retain(|a| a != p);
414 }
415 }
416 Ok(None)
417 }
418 Command::AutoView(types) => {
419 for t in types {
420 cfg.filters.entry(t.to_lowercase()).or_default();
423 }
424 Ok(None)
425 }
426 Command::UnAutoView(types) => {
427 for t in types {
428 if t == "*" {
429 cfg.filters.clear();
430 } else {
431 cfg.filters.remove(&t.to_lowercase());
432 }
433 }
434 Ok(None)
435 }
436 Command::AlternativeOrder(types) => {
437 for t in types {
438 let t = t.to_lowercase();
439 if !cfg.pager.alternative_order.contains(&t) {
440 cfg.pager.alternative_order.push(t);
441 }
442 }
443 Ok(None)
444 }
445 Command::UnAlternativeOrder(types) => {
446 for t in types {
447 if t == "*" {
448 cfg.pager.alternative_order.clear();
449 } else {
450 let t = t.to_lowercase();
451 cfg.pager.alternative_order.retain(|a| *a != t);
452 }
453 }
454 Ok(None)
455 }
456 Command::MyHdr(entry) => {
457 if let Some((name, _)) = entry.split_once(':') {
460 let name = name.trim().to_string();
461 cfg.mail.my_hdr.retain(|h| !header_named(h, &name));
462 }
463 cfg.mail.my_hdr.push(entry.clone());
464 Ok(None)
465 }
466 Command::UnMyHdr(names) => {
467 for name in names {
468 if name == "*" {
469 cfg.mail.my_hdr.clear();
470 } else {
471 let name = name.trim_end_matches(':');
472 cfg.mail.my_hdr.retain(|h| !header_named(h, name));
473 }
474 }
475 Ok(None)
476 }
477 Command::Bind { .. }
478 | Command::Macro { .. }
479 | Command::Alias { .. }
480 | Command::Push(_)
481 | Command::Exec(_) => Ok(None),
482 }
483}
484
485fn header_named(entry: &str, name: &str) -> bool {
487 entry
488 .split_once(':')
489 .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case(name))
490}
491
492fn unknown(name: &str) -> String {
493 format!("unknown or read-only option {name:?}")
494}
495
496fn set(cfg: &mut Config, name: &str, value: &str) -> Result<(), String> {
497 match name {
500 "from" => {
501 let (display, addr) = split_from(value);
502 if let Some(display) = display {
503 cfg.identity.name = Some(display);
504 }
505 cfg.identity.email = Some(addr);
506 return Ok(());
507 }
508 "mime_forward" => {
509 cfg.mail.forward = Some(if is_yes(value) { "attach" } else { "inline" }.into());
510 return Ok(());
511 }
512 _ => {}
513 }
514 if name == "sort" || name == "sort_aux" {
515 let base = value.strip_prefix("reverse-").unwrap_or(value);
516 let known = if name == "sort" {
517 SORTS.contains(&base)
518 } else {
519 base == "last-date-sent" || base == "date"
520 };
521 if !known {
522 return Err(format!("no such sort order {value:?}"));
523 }
524 }
525 let num = |v: &str| -> Result<i64, String> {
526 v.parse::<i64>()
527 .map_err(|_| format!("{name} wants a number, got {v:?}"))
528 };
529 match slot(cfg, name).ok_or_else(|| unknown(name))? {
530 Slot::Text(field) => *field = Some(value.to_string()),
531 Slot::Flag(field) => *field = is_yes(value),
532 Slot::FlagOpt(field) => *field = Some(is_yes(value)),
533 Slot::Num16(field) => *field = u16::try_from(num(value)?.max(0)).unwrap_or(u16::MAX),
534 Slot::NumUsize(field) => *field = usize::try_from(num(value)?.max(0)).unwrap_or(0),
535 Slot::NumU64(field) => *field = num(value)?.max(0) as u64,
536 Slot::NumU64Opt(field) => *field = Some(num(value)?.max(0) as u64),
537 Slot::NumI64Opt(field) => *field = Some(num(value)?),
538 }
539 Ok(())
540}
541
542fn unset(cfg: &mut Config, name: &str) -> Result<(), String> {
546 if name == "from" {
547 cfg.identity.email = None;
548 return Ok(());
549 }
550 match slot(cfg, name).ok_or_else(|| unknown(name))? {
551 Slot::Text(field) => *field = None,
552 Slot::Flag(field) => *field = false,
553 Slot::FlagOpt(field) => *field = Some(false),
554 Slot::Num16(field) => *field = 0,
555 Slot::NumUsize(field) => *field = 0,
556 Slot::NumU64(field) => *field = 0,
557 Slot::NumU64Opt(field) => *field = None,
558 Slot::NumI64Opt(field) => *field = None,
559 }
560 Ok(())
561}
562
563fn toggle(cfg: &mut Config, name: &str) -> Result<(), String> {
564 match slot(cfg, name).ok_or_else(|| unknown(name))? {
565 Slot::Flag(field) => *field = !*field,
566 Slot::FlagOpt(field) => *field = Some(!field.unwrap_or(false)),
567 _ => return Err(format!("{name} is not a boolean")),
568 }
569 Ok(())
570}
571
572fn query(cfg: &mut Config, name: &str) -> Result<String, String> {
573 if name == "from" {
574 let value = cfg.identity.email.clone().unwrap_or_default();
575 return Ok(format!("from=\"{value}\""));
576 }
577 let text = match slot(cfg, name).ok_or_else(|| unknown(name))? {
578 Slot::Text(field) => format!("{name}=\"{}\"", field.clone().unwrap_or_default()),
579 Slot::Flag(field) => flag_text(name, *field),
580 Slot::FlagOpt(field) => flag_text(name, field.unwrap_or(false)),
581 Slot::Num16(field) => format!("{name}={field}"),
582 Slot::NumUsize(field) => format!("{name}={field}"),
583 Slot::NumU64(field) => format!("{name}={field}"),
584 Slot::NumU64Opt(field) => match field {
585 Some(n) => format!("{name}={n}"),
586 None => format!("{name} is unset"),
587 },
588 Slot::NumI64Opt(field) => match field {
589 Some(n) => format!("{name}={n}"),
590 None => format!("{name} is unset"),
591 },
592 };
593 Ok(text)
594}
595
596fn flag_text(name: &str, on: bool) -> String {
597 if on {
598 name.to_string()
599 } else {
600 format!("no{name}")
601 }
602}
603
604fn edit_header_list(cfg: &mut Config, headers: &[String], ignoring: bool) {
608 let clean: Vec<String> = headers
609 .iter()
610 .map(|h| h.trim_end_matches(':').to_lowercase())
611 .collect();
612 let (add, remove) = if ignoring {
613 (&mut cfg.pager.ignore, &mut cfg.pager.unignore)
614 } else {
615 (&mut cfg.pager.unignore, &mut cfg.pager.ignore)
616 };
617 let list = add.get_or_insert_with(Vec::new);
618 for name in &clean {
619 if !list.contains(name) {
620 list.push(name.clone());
621 }
622 }
623 if let Some(list) = remove {
624 list.retain(|n| !clean.contains(n));
625 }
626}
627
628fn color(
632 cfg: &mut Config,
633 object: &str,
634 fg: &str,
635 bg: &str,
636 pattern: Option<&str>,
637) -> Result<(), String> {
638 let fg = convert_color(fg);
639 let bg = convert_color(bg);
640 let vivid = if matches!(fg.as_str(), "black" | "white" | "default") && bg != "default" {
643 bg.clone()
644 } else {
645 fg.clone()
646 };
647 if let Some(n) = object.strip_prefix("quoted")
648 && let Ok(depth) = if n.is_empty() { Ok(0usize) } else { n.parse() }
649 {
650 let key = if depth == 0 {
651 "quoted".to_string()
652 } else {
653 format!("quoted{depth}")
654 };
655 cfg.colors.insert(key, vivid);
656 return Ok(());
657 }
658 let mut named = |key: &str, value: String| {
659 cfg.colors.insert(key.to_string(), value);
660 };
661 match (object, pattern) {
662 ("status", _) => {
663 named("status_fg", fg);
664 named("status_bg", bg);
665 }
666 ("search", _) => {
667 if fg != "default" {
668 named("search_fg", fg);
669 }
670 if bg != "default" {
671 named("search_bg", bg);
672 }
673 }
674 ("header" | "hdrdefault", _) => named("header", fg),
675 ("error", _) => named("error", vivid),
676 ("index", Some("~D")) if bg == "default" => named("deleted", vivid),
679 ("index", Some("~F")) if bg == "default" => named("flagged", vivid),
680 ("index", Some("~T")) if bg == "default" => named("tagged", vivid),
681 ("index", Some(pattern)) => {
682 crate::pattern::parse(pattern).map_err(|err| format!("bad pattern: {err}"))?;
683 cfg.color_index.push(rule(pattern, fg, bg));
684 }
685 ("body", Some(pattern)) => {
686 regex_lite::Regex::new(pattern).map_err(|err| format!("bad regex: {err}"))?;
687 cfg.color_body.push(rule(pattern, fg, bg));
688 }
689 ("index" | "body", None) => return Err(format!("color {object} needs a pattern")),
690 _ => return Err(format!("no rmut color slot for {object:?}")),
691 }
692 Ok(())
693}
694
695fn rule(pattern: &str, fg: String, bg: String) -> ColorRule {
696 ColorRule {
697 pattern: pattern.to_string(),
698 fg: (fg != "default").then_some(fg),
699 bg: (bg != "default").then_some(bg),
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 fn one(line: &str) -> Command {
708 let mut cmds = parse(line).expect("parses");
709 assert_eq!(cmds.len(), 1, "{line:?} yielded {cmds:?}");
710 cmds.pop().unwrap()
711 }
712
713 #[test]
714 fn alternates_and_my_hdr_at_the_prompt() {
715 let mut cfg = Config::default();
716 for line in [
717 "alternates jane@old\\.example\\.com typo@x",
718 "unalternates typo@x",
719 "my_hdr Organization: Acme",
720 "my_hdr X-Mailer: rmut",
721 "my_hdr Organization: Acme Ltd",
722 "unmy_hdr X-Mailer",
723 "set metoo",
724 ] {
725 for cmd in parse(line).expect(line) {
726 apply(&mut cfg, &cmd).expect(line);
727 }
728 }
729 assert_eq!(cfg.mail.alternates, ["jane@old\\.example\\.com"]);
730 assert_eq!(cfg.mail.my_hdr, ["Organization: Acme Ltd"]);
731 assert!(cfg.mail.metoo);
732 for cmd in parse("unmy_hdr *").unwrap() {
733 apply(&mut cfg, &cmd).unwrap();
734 }
735 assert!(cfg.mail.my_hdr.is_empty());
736 assert!(parse("my_hdr Organization").is_err());
737 assert!(parse("alternates").is_err());
738 }
739
740 #[test]
741 fn auto_view_and_alternative_order_at_the_prompt() {
742 let mut cfg = Config::default();
743 for line in [
744 "auto_view text/html Application/Zip",
745 "auto_view text/calendar",
746 "unauto_view text/calendar",
747 "alternative_order text/plain TEXT/HTML",
748 "alternative_order text/plain",
749 "unalternative_order text/html",
750 ] {
751 for cmd in parse(line).expect(line) {
752 apply(&mut cfg, &cmd).expect(line);
753 }
754 }
755 let mut types: Vec<&str> = cfg.filters.keys().map(String::as_str).collect();
757 types.sort_unstable();
758 assert_eq!(types, ["application/zip", "text/html"]);
759 assert!(cfg.filters.values().all(String::is_empty));
760 cfg.filters
762 .insert("text/html".into(), "w3m -dump -T text/html".into());
763 for cmd in parse("auto_view text/html").unwrap() {
764 apply(&mut cfg, &cmd).unwrap();
765 }
766 assert_eq!(
767 cfg.filters.get("text/html").map(String::as_str),
768 Some("w3m -dump -T text/html")
769 );
770 assert_eq!(cfg.pager.alternative_order, ["text/plain"]);
772 for cmd in parse("unauto_view *").unwrap() {
773 apply(&mut cfg, &cmd).unwrap();
774 }
775 assert!(cfg.filters.is_empty());
776 assert!(parse("auto_view").is_err());
777 assert!(parse("alternative_order").is_err());
778 }
779
780 #[test]
781 fn set_forms() {
782 assert_eq!(
783 one("set index_format=\"%s %f\""),
784 Command::Set {
785 name: "index_format".into(),
786 value: "%s %f".into()
787 }
788 );
789 assert_eq!(one("set nobeep"), Command::Unset("beep".into()));
790 assert_eq!(
791 one("set beep"),
792 Command::Set {
793 name: "beep".into(),
794 value: "yes".into()
795 }
796 );
797 assert_eq!(one("set invbeep"), Command::Toggle("beep".into()));
798 assert_eq!(one("set beep?"), Command::Query("beep".into()));
799 assert_eq!(one("toggle tilde"), Command::Toggle("tilde".into()));
800 assert_eq!(one("unset tilde"), Command::Unset("tilde".into()));
801 assert_eq!(parse("set a=1 b=2").unwrap().len(), 2);
802 assert_eq!(parse("").unwrap(), vec![]);
803 assert_eq!(parse("# comment").unwrap(), vec![]);
804 }
805
806 #[test]
807 fn unknown_command_and_option_report() {
808 assert!(parse("frobnicate x").is_err());
809 let mut cfg = Config::default();
810 let err = apply(&mut cfg, &one("set nosuchthing=1")).unwrap_err();
811 assert!(err.contains("nosuchthing"), "{err}");
812 }
813
814 #[test]
815 fn set_applies_to_config() {
816 let mut cfg = Config::default();
817 apply(&mut cfg, &one("set pager_index_lines=5")).unwrap();
818 assert_eq!(cfg.pager.index_lines, 5);
819 apply(&mut cfg, &one("set wrap=-8")).unwrap();
820 assert_eq!(cfg.pager.wrap, Some(-8));
821 apply(&mut cfg, &one("set tilde")).unwrap();
822 assert!(cfg.pager.tilde);
823 apply(&mut cfg, &one("toggle tilde")).unwrap();
824 assert!(!cfg.pager.tilde);
825 apply(&mut cfg, &one("set from=\"Jane Doe <jane@x>\"")).unwrap();
826 assert_eq!(cfg.identity.email.as_deref(), Some("jane@x"));
827 assert_eq!(cfg.identity.name.as_deref(), Some("Jane Doe"));
828 assert!(apply(&mut cfg, &one("set pager_index_lines=x")).is_err());
830 assert!(apply(&mut cfg, &one("set sort=nonsense")).is_err());
831 apply(&mut cfg, &one("set sort=reverse-date")).unwrap();
832 assert_eq!(cfg.index.sort.as_deref(), Some("reverse-date"));
833 }
834
835 #[test]
836 fn query_reports_current_value() {
837 let mut cfg = Config::default();
838 cfg.index.format = Some("%s".into());
839 let out = apply(&mut cfg, &one("set index_format?")).unwrap();
840 assert_eq!(out.as_deref(), Some("index_format=\"%s\""));
841 let out = apply(&mut cfg, &one("set beep?")).unwrap();
842 assert_eq!(out.as_deref(), Some("beep"));
843 apply(&mut cfg, &one("unset beep")).unwrap();
844 let out = apply(&mut cfg, &one("set beep?")).unwrap();
845 assert_eq!(out.as_deref(), Some("nobeep"));
846 }
847
848 #[test]
849 fn ignore_lists_move_names_between_them() {
850 let mut cfg = Config::default();
851 apply(&mut cfg, &one("ignore x-spam: received")).unwrap();
852 assert_eq!(
853 cfg.pager.ignore.as_deref(),
854 Some(["x-spam".to_string(), "received".to_string()].as_slice())
855 );
856 apply(&mut cfg, &one("unignore received")).unwrap();
857 assert_eq!(
858 cfg.pager.ignore.as_deref(),
859 Some(["x-spam".to_string()].as_slice())
860 );
861 assert_eq!(
862 cfg.pager.unignore.as_deref(),
863 Some(["received".to_string()].as_slice())
864 );
865 }
866
867 #[test]
868 fn colors_land_in_their_slots() {
869 let mut cfg = Config::default();
870 apply(&mut cfg, &one("color status brightwhite blue")).unwrap();
871 assert_eq!(
872 cfg.colors.get("status_fg").map(String::as_str),
873 Some("lightwhite")
874 );
875 assert_eq!(
876 cfg.colors.get("status_bg").map(String::as_str),
877 Some("blue")
878 );
879 apply(&mut cfg, &one("color quoted2 green default")).unwrap();
880 assert_eq!(cfg.colors.get("quoted2").map(String::as_str), Some("green"));
881 apply(&mut cfg, &one("color index red default ~F")).unwrap();
882 assert_eq!(cfg.colors.get("flagged").map(String::as_str), Some("red"));
883 apply(&mut cfg, &one("color index red default ~f jane")).unwrap();
884 assert_eq!(cfg.color_index.len(), 1);
885 apply(&mut cfg, &one("color body yellow default TODO")).unwrap();
886 assert_eq!(cfg.color_body.len(), 1);
887 assert!(apply(&mut cfg, &one("color index red default ~q")).is_err());
889 assert!(apply(&mut cfg, &one("color body red default [")).is_err());
890 assert!(apply(&mut cfg, &one("color nosuchslot red default")).is_err());
891 }
892
893 #[test]
894 fn bind_macro_and_push_parse_but_stay_for_the_caller() {
895 assert_eq!(
896 one("bind index \\Cd delete-message"),
897 Command::Bind {
898 menu: Menu::Index,
899 key: "\\Cd".into(),
900 function: "delete-message".into()
901 }
902 );
903 assert_eq!(parse("bind index,pager d delete-message").unwrap().len(), 2);
904 assert!(parse("bind nosuchmenu d delete").is_err());
905 assert_eq!(
906 one("macro index L \"l~f jane<enter>\""),
907 Command::Macro {
908 menu: Menu::Index,
909 key: "L".into(),
910 seq: "l~f jane<enter>".into()
911 }
912 );
913 assert_eq!(one("push \"<enter>\""), Command::Push("<enter>".into()));
914 assert_eq!(one("exec sync"), Command::Exec("sync".into()));
915 assert_eq!(
916 one("alias jane Jane Doe <jane@x>"),
917 Command::Alias {
918 nick: "jane".into(),
919 expansion: "Jane Doe <jane@x>".into()
920 }
921 );
922 let mut cfg = Config::default();
924 assert_eq!(apply(&mut cfg, &one("exec sync")).unwrap(), None);
925 }
926}