1use thiserror::Error;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum CardinalCommand {
9 List(ListTarget),
10 Open(Selector),
11 Compose,
12 Reply { all: bool },
13 Forward { recipient: String },
14 Archive,
15 Delete,
16 Spam,
17 Mark(MarkState),
18 Move { target: String },
19 Send { confirm: bool },
20 Search { query: String },
21 Calendar(CalendarView),
22 Agenda(AgendaRange),
23 Event(EventCommand),
24 Invite(InviteCommand),
25 Sync(SyncTarget),
26 Undo,
27 Help,
28 Bindings,
29 Config,
30 Reload,
31 Quit,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ListTarget {
36 Inboxes,
37 Folders,
38 Mail,
39 Unread,
40 Flagged,
41 Calendars,
42 Invites,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Selector {
47 Index(usize),
48 Name(String),
49 Current,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum MarkState {
54 Read,
55 Unread,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum CalendarView {
60 Today,
61 Tomorrow,
62 Week,
63 Month,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum AgendaRange {
68 Default,
69 Today,
70 Tomorrow,
71 Week,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum EventCommand {
76 New,
77 Open(Selector),
78 Edit(Selector),
79 Delete(Selector),
80 Duplicate(Selector),
81 Move { calendar: String },
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum InviteCommand {
86 Accept,
87 Tentative,
88 Decline,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum SyncTarget {
93 All,
94 Mail,
95 Calendar,
96 Contacts,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Error)]
100pub enum ParseError {
101 #[error("empty command")]
102 Empty,
103 #[error("unknown command: {0}")]
104 UnknownCommand(String),
105 #[error("missing argument: {0}")]
106 MissingArgument(&'static str),
107 #[error("invalid argument for {command}: {argument}")]
108 InvalidArgument {
109 command: &'static str,
110 argument: String,
111 },
112 #[error("unexpected argument for {command}: {argument}")]
113 UnexpectedArgument {
114 command: &'static str,
115 argument: String,
116 },
117 #[error("unterminated quoted string")]
118 UnterminatedQuote,
119}
120
121pub fn parse_command(input: &str) -> Result<CardinalCommand, ParseError> {
126 let trimmed = input.trim();
127 let body = trimmed.strip_prefix(':').unwrap_or(trimmed).trim();
128
129 if body.is_empty() {
130 return Err(ParseError::Empty);
131 }
132
133 let tokens = tokenize(body)?;
134 if tokens.is_empty() {
135 return Err(ParseError::Empty);
136 }
137
138 match tokens[0].as_str() {
139 "list" => parse_list(&tokens),
140 "open" => parse_open(&tokens),
141 "compose" => Ok(CardinalCommand::Compose),
142 "reply" => parse_reply(&tokens),
143 "forward" => parse_forward(&tokens),
144 "archive" => Ok(CardinalCommand::Archive),
145 "delete" => Ok(CardinalCommand::Delete),
146 "spam" | "spamit" => Ok(CardinalCommand::Spam),
147 "mark" => parse_mark(&tokens),
148 "move" => parse_move(&tokens),
149 "send" => parse_send(&tokens),
150 "search" => parse_search(&tokens),
151 "calendar" => parse_calendar(&tokens),
152 "agenda" => parse_agenda(&tokens),
153 "event" => parse_event(&tokens),
154 "invite" => parse_invite(&tokens),
155 "sync" => parse_sync(&tokens),
156 "undo" => {
157 reject_extra_arguments(&tokens, 1, "undo")?;
158 Ok(CardinalCommand::Undo)
159 }
160 "help" => Ok(CardinalCommand::Help),
161 "bindings" => Ok(CardinalCommand::Bindings),
162 "config" => Ok(CardinalCommand::Config),
163 "reload" => Ok(CardinalCommand::Reload),
164 "quit" | "q" => Ok(CardinalCommand::Quit),
165 other => Err(ParseError::UnknownCommand(other.to_owned())),
166 }
167}
168
169fn parse_list(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
170 let target = required(tokens, 1, "list target")?;
171 reject_extra_arguments(tokens, 2, "list")?;
172 let target = match target.as_str() {
173 "inboxes" => ListTarget::Inboxes,
174 "folders" => ListTarget::Folders,
175 "mail" => ListTarget::Mail,
176 "unread" => ListTarget::Unread,
177 "flagged" => ListTarget::Flagged,
178 "calendars" => ListTarget::Calendars,
179 "invites" => ListTarget::Invites,
180 other => {
181 return Err(ParseError::InvalidArgument {
182 command: "list",
183 argument: other.to_owned(),
184 })
185 }
186 };
187 Ok(CardinalCommand::List(target))
188}
189
190fn parse_open(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
191 let target = required(tokens, 1, "open target")?;
192 reject_extra_arguments(tokens, 2, "open")?;
193 Ok(CardinalCommand::Open(parse_selector(target)))
194}
195
196fn parse_reply(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
197 let all = match tokens.get(1).map(String::as_str) {
198 None => false,
199 Some("all") => true,
200 Some(other) => {
201 return Err(ParseError::InvalidArgument {
202 command: "reply",
203 argument: other.to_owned(),
204 })
205 }
206 };
207 reject_extra_arguments(tokens, 2, "reply")?;
208 Ok(CardinalCommand::Reply { all })
209}
210
211fn parse_forward(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
212 let recipient = required(tokens, 1, "forward recipient")?.to_owned();
213 reject_extra_arguments(tokens, 2, "forward")?;
214 Ok(CardinalCommand::Forward { recipient })
215}
216
217fn parse_mark(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
218 let state = match required(tokens, 1, "mark state")?.as_str() {
219 "read" => MarkState::Read,
220 "unread" => MarkState::Unread,
221 other => {
222 return Err(ParseError::InvalidArgument {
223 command: "mark",
224 argument: other.to_owned(),
225 })
226 }
227 };
228 reject_extra_arguments(tokens, 2, "mark")?;
229 Ok(CardinalCommand::Mark(state))
230}
231
232fn parse_move(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
233 let target = required(tokens, 1, "move target")?.to_owned();
234 reject_extra_arguments(tokens, 2, "move")?;
235 Ok(CardinalCommand::Move { target })
236}
237
238fn parse_search(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
239 if tokens.len() < 2 {
240 return Err(ParseError::MissingArgument("search query"));
241 }
242 Ok(CardinalCommand::Search {
243 query: tokens[1..].join(" "),
244 })
245}
246
247fn parse_send(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
248 reject_extra_arguments(tokens, 2, "send")?;
249 let confirm = match tokens.get(1).map(String::as_str) {
250 None => false,
251 Some("confirm") => true,
252 Some(other) => {
253 return Err(ParseError::InvalidArgument {
254 command: "send",
255 argument: other.to_owned(),
256 })
257 }
258 };
259 Ok(CardinalCommand::Send { confirm })
260}
261
262fn parse_calendar(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
263 let view = match required(tokens, 1, "calendar view")?.as_str() {
264 "today" => CalendarView::Today,
265 "tomorrow" => CalendarView::Tomorrow,
266 "week" => CalendarView::Week,
267 "month" => CalendarView::Month,
268 other => {
269 return Err(ParseError::InvalidArgument {
270 command: "calendar",
271 argument: other.to_owned(),
272 })
273 }
274 };
275 reject_extra_arguments(tokens, 2, "calendar")?;
276 Ok(CardinalCommand::Calendar(view))
277}
278
279fn parse_agenda(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
280 reject_extra_arguments(tokens, 2, "agenda")?;
281 let range = match tokens.get(1).map(String::as_str) {
282 None => AgendaRange::Default,
283 Some("today") => AgendaRange::Today,
284 Some("tomorrow") => AgendaRange::Tomorrow,
285 Some("week") => AgendaRange::Week,
286 Some(other) => {
287 return Err(ParseError::InvalidArgument {
288 command: "agenda",
289 argument: other.to_owned(),
290 })
291 }
292 };
293 Ok(CardinalCommand::Agenda(range))
294}
295
296fn parse_event(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
297 let subcommand = required(tokens, 1, "event subcommand")?;
298 let command = match subcommand.as_str() {
299 "new" => {
300 reject_extra_arguments(tokens, 2, "event new")?;
301 EventCommand::New
302 }
303 "open" => {
304 let selector = parse_selector(required(tokens, 2, "event open selector")?);
305 reject_extra_arguments(tokens, 3, "event open")?;
306 EventCommand::Open(selector)
307 }
308 "edit" => {
309 reject_extra_arguments(tokens, 3, "event edit")?;
310 EventCommand::Edit(parse_optional_selector(tokens.get(2).map(String::as_str)))
311 }
312 "delete" => {
313 reject_extra_arguments(tokens, 3, "event delete")?;
314 EventCommand::Delete(parse_optional_selector(tokens.get(2).map(String::as_str)))
315 }
316 "duplicate" => {
317 reject_extra_arguments(tokens, 2, "event duplicate")?;
318 EventCommand::Duplicate(Selector::Current)
319 }
320 "move" => {
321 let calendar = required(tokens, 2, "event move calendar")?.to_owned();
322 reject_extra_arguments(tokens, 3, "event move")?;
323 EventCommand::Move { calendar }
324 }
325 other => {
326 return Err(ParseError::InvalidArgument {
327 command: "event",
328 argument: other.to_owned(),
329 })
330 }
331 };
332 Ok(CardinalCommand::Event(command))
333}
334
335fn parse_invite(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
336 let command = match required(tokens, 1, "invite action")?.as_str() {
337 "accept" => InviteCommand::Accept,
338 "tentative" => InviteCommand::Tentative,
339 "decline" => InviteCommand::Decline,
340 other => {
341 return Err(ParseError::InvalidArgument {
342 command: "invite",
343 argument: other.to_owned(),
344 })
345 }
346 };
347 reject_extra_arguments(tokens, 2, "invite")?;
348 Ok(CardinalCommand::Invite(command))
349}
350
351fn parse_sync(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
352 reject_extra_arguments(tokens, 2, "sync")?;
353 let target = match tokens.get(1).map(String::as_str) {
354 None => SyncTarget::All,
355 Some("mail") => SyncTarget::Mail,
356 Some("calendar") => SyncTarget::Calendar,
357 Some("contacts") => SyncTarget::Contacts,
358 Some(other) => {
359 return Err(ParseError::InvalidArgument {
360 command: "sync",
361 argument: other.to_owned(),
362 })
363 }
364 };
365 Ok(CardinalCommand::Sync(target))
366}
367
368fn parse_selector(value: &str) -> Selector {
369 match value.parse::<usize>() {
370 Ok(index) => Selector::Index(index),
371 Err(_) => Selector::Name(value.to_owned()),
372 }
373}
374
375fn parse_optional_selector(value: Option<&str>) -> Selector {
376 match value {
377 Some(value) => parse_selector(value),
378 None => Selector::Current,
379 }
380}
381
382fn required<'a>(
383 tokens: &'a [String],
384 index: usize,
385 name: &'static str,
386) -> Result<&'a String, ParseError> {
387 tokens.get(index).ok_or(ParseError::MissingArgument(name))
388}
389
390fn reject_extra_arguments(
391 tokens: &[String],
392 expected_len: usize,
393 command: &'static str,
394) -> Result<(), ParseError> {
395 if let Some(argument) = tokens.get(expected_len) {
396 return Err(ParseError::UnexpectedArgument {
397 command,
398 argument: argument.to_owned(),
399 });
400 }
401 Ok(())
402}
403
404fn tokenize(input: &str) -> Result<Vec<String>, ParseError> {
405 let mut tokens = Vec::new();
406 let mut current = String::new();
407 let mut chars = input.chars().peekable();
408 let mut in_quotes = false;
409
410 while let Some(ch) = chars.next() {
411 match ch {
412 '"' => in_quotes = !in_quotes,
413 '\\' => match chars.next() {
414 Some(next) => current.push(next),
415 None => current.push('\\'),
416 },
417 c if c.is_whitespace() && !in_quotes => {
418 if !current.is_empty() {
419 tokens.push(std::mem::take(&mut current));
420 }
421 }
422 c => current.push(c),
423 }
424 }
425
426 if in_quotes {
427 return Err(ParseError::UnterminatedQuote);
428 }
429
430 if !current.is_empty() {
431 tokens.push(current);
432 }
433
434 Ok(tokens)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn parses_list_inboxes() {
443 assert_eq!(
444 parse_command(":list inboxes"),
445 Ok(CardinalCommand::List(ListTarget::Inboxes))
446 );
447 }
448
449 #[test]
450 fn parses_open_index() {
451 assert_eq!(
452 parse_command(":open 4"),
453 Ok(CardinalCommand::Open(Selector::Index(4)))
454 );
455 }
456
457 #[test]
458 fn parses_open_name() {
459 assert_eq!(
460 parse_command(":open personal"),
461 Ok(CardinalCommand::Open(Selector::Name("personal".into())))
462 );
463 }
464
465 #[test]
466 fn parses_reply_all() {
467 assert_eq!(
468 parse_command(":reply all"),
469 Ok(CardinalCommand::Reply { all: true })
470 );
471 }
472
473 #[test]
474 fn parses_reply_without_all() {
475 assert_eq!(
476 parse_command(":reply"),
477 Ok(CardinalCommand::Reply { all: false })
478 );
479 }
480
481 #[test]
482 fn parses_spamit_alias() {
483 assert_eq!(parse_command(":spamit"), Ok(CardinalCommand::Spam));
484 }
485
486 #[test]
487 fn parses_calendar_week() {
488 assert_eq!(
489 parse_command(":calendar week"),
490 Ok(CardinalCommand::Calendar(CalendarView::Week))
491 );
492 }
493
494 #[test]
495 fn parses_agenda_default() {
496 assert_eq!(
497 parse_command(":agenda"),
498 Ok(CardinalCommand::Agenda(AgendaRange::Default))
499 );
500 }
501
502 #[test]
503 fn parses_event_delete_current() {
504 assert_eq!(
505 parse_command(":event delete"),
506 Ok(CardinalCommand::Event(EventCommand::Delete(
507 Selector::Current
508 )))
509 );
510 }
511
512 #[test]
513 fn parses_event_open_index() {
514 assert_eq!(
515 parse_command(":event open 2"),
516 Ok(CardinalCommand::Event(EventCommand::Open(Selector::Index(
517 2
518 ))))
519 );
520 }
521
522 #[test]
523 fn parses_event_duplicate_current() {
524 assert_eq!(
525 parse_command(":event duplicate"),
526 Ok(CardinalCommand::Event(EventCommand::Duplicate(
527 Selector::Current
528 )))
529 );
530 }
531
532 #[test]
533 fn parses_invite_accept() {
534 assert_eq!(
535 parse_command(":invite accept"),
536 Ok(CardinalCommand::Invite(InviteCommand::Accept))
537 );
538 }
539
540 #[test]
541 fn parses_quoted_search_query() {
542 assert_eq!(
543 parse_command(":search \"hello world\""),
544 Ok(CardinalCommand::Search {
545 query: "hello world".into()
546 })
547 );
548 }
549
550 #[test]
551 fn rejects_empty_command() {
552 assert_eq!(parse_command(":"), Err(ParseError::Empty));
553 }
554
555 #[test]
556 fn rejects_unknown_command() {
557 assert_eq!(
558 parse_command(":explode"),
559 Err(ParseError::UnknownCommand("explode".into()))
560 );
561 }
562
563 #[test]
564 fn rejects_undocumented_aliases() {
565 assert_eq!(
566 parse_command(":ls inboxes"),
567 Err(ParseError::UnknownCommand("ls".into()))
568 );
569 assert_eq!(
570 parse_command(":cal week"),
571 Err(ParseError::UnknownCommand("cal".into()))
572 );
573 assert_eq!(
574 parse_command(":/ query"),
575 Err(ParseError::UnknownCommand("/".into()))
576 );
577 }
578
579 #[test]
580 fn rejects_reply_invalid_argument() {
581 assert_eq!(
582 parse_command(":reply team"),
583 Err(ParseError::InvalidArgument {
584 command: "reply",
585 argument: "team".into(),
586 })
587 );
588 }
589
590 #[test]
591 fn rejects_extra_argument_after_open() {
592 assert_eq!(
593 parse_command(":open 2 extra"),
594 Err(ParseError::UnexpectedArgument {
595 command: "open",
596 argument: "extra".into(),
597 })
598 );
599 }
600
601 #[test]
602 fn rejects_extra_argument_after_event_edit() {
603 assert_eq!(
604 parse_command(":event edit 1 extra"),
605 Err(ParseError::UnexpectedArgument {
606 command: "event edit",
607 argument: "extra".into(),
608 })
609 );
610 }
611
612 #[test]
613 fn rejects_missing_event_open_selector() {
614 assert_eq!(
615 parse_command(":event open"),
616 Err(ParseError::MissingArgument("event open selector"))
617 );
618 }
619
620 #[test]
621 fn rejects_event_duplicate_selector() {
622 assert_eq!(
623 parse_command(":event duplicate 2"),
624 Err(ParseError::UnexpectedArgument {
625 command: "event duplicate",
626 argument: "2".into(),
627 })
628 );
629 }
630
631 #[test]
632 fn parses_command_without_colon_and_with_whitespace() {
633 assert_eq!(
634 parse_command(" list mail "),
635 Ok(CardinalCommand::List(ListTarget::Mail))
636 );
637 }
638
639 #[test]
640 fn parses_help_bindings_config_reload_and_quit_alias() {
641 assert_eq!(parse_command(":help"), Ok(CardinalCommand::Help));
642 assert_eq!(parse_command(":bindings"), Ok(CardinalCommand::Bindings));
643 assert_eq!(parse_command(":config"), Ok(CardinalCommand::Config));
644 assert_eq!(parse_command(":reload"), Ok(CardinalCommand::Reload));
645 assert_eq!(parse_command(":undo"), Ok(CardinalCommand::Undo));
646 assert_eq!(parse_command(":quit"), Ok(CardinalCommand::Quit));
647 assert_eq!(parse_command(":q"), Ok(CardinalCommand::Quit));
648 }
649
650 #[test]
651 fn parses_compose_archive_delete_and_sync_variants() {
652 assert_eq!(parse_command(":compose"), Ok(CardinalCommand::Compose));
653 assert_eq!(parse_command(":archive"), Ok(CardinalCommand::Archive));
654 assert_eq!(parse_command(":delete"), Ok(CardinalCommand::Delete));
655 assert_eq!(
656 parse_command(":send"),
657 Ok(CardinalCommand::Send { confirm: false })
658 );
659 assert_eq!(
660 parse_command(":send confirm"),
661 Ok(CardinalCommand::Send { confirm: true })
662 );
663 assert_eq!(
664 parse_command(":sync"),
665 Ok(CardinalCommand::Sync(SyncTarget::All))
666 );
667 assert_eq!(
668 parse_command(":sync mail"),
669 Ok(CardinalCommand::Sync(SyncTarget::Mail))
670 );
671 assert_eq!(
672 parse_command(":sync calendar"),
673 Ok(CardinalCommand::Sync(SyncTarget::Calendar))
674 );
675 assert_eq!(
676 parse_command(":sync contacts"),
677 Ok(CardinalCommand::Sync(SyncTarget::Contacts))
678 );
679 }
680
681 #[test]
682 fn parses_mark_and_move_and_forward() {
683 assert_eq!(
684 parse_command(":mark read"),
685 Ok(CardinalCommand::Mark(MarkState::Read))
686 );
687 assert_eq!(
688 parse_command(":mark unread"),
689 Ok(CardinalCommand::Mark(MarkState::Unread))
690 );
691 assert_eq!(
692 parse_command(":move archive"),
693 Ok(CardinalCommand::Move {
694 target: "archive".into()
695 })
696 );
697 assert_eq!(
698 parse_command(":forward alice@example.com"),
699 Ok(CardinalCommand::Forward {
700 recipient: "alice@example.com".into()
701 })
702 );
703 }
704
705 #[test]
706 fn parses_list_variants() {
707 assert_eq!(
708 parse_command(":list folders"),
709 Ok(CardinalCommand::List(ListTarget::Folders))
710 );
711 assert_eq!(
712 parse_command(":list unread"),
713 Ok(CardinalCommand::List(ListTarget::Unread))
714 );
715 assert_eq!(
716 parse_command(":list flagged"),
717 Ok(CardinalCommand::List(ListTarget::Flagged))
718 );
719 assert_eq!(
720 parse_command(":list calendars"),
721 Ok(CardinalCommand::List(ListTarget::Calendars))
722 );
723 assert_eq!(
724 parse_command(":list invites"),
725 Ok(CardinalCommand::List(ListTarget::Invites))
726 );
727 }
728
729 #[test]
730 fn parses_search_with_multiple_tokens_and_escaped_quote() {
731 assert_eq!(
732 parse_command(":search from:alice subject:invoice"),
733 Ok(CardinalCommand::Search {
734 query: "from:alice subject:invoice".into()
735 })
736 );
737 assert_eq!(
738 parse_command(":search \"hello \\\"team\\\"\""),
739 Ok(CardinalCommand::Search {
740 query: "hello \"team\"".into()
741 })
742 );
743 }
744
745 #[test]
746 fn parses_agenda_and_invite_variants() {
747 assert_eq!(
748 parse_command(":agenda today"),
749 Ok(CardinalCommand::Agenda(AgendaRange::Today))
750 );
751 assert_eq!(
752 parse_command(":agenda tomorrow"),
753 Ok(CardinalCommand::Agenda(AgendaRange::Tomorrow))
754 );
755 assert_eq!(
756 parse_command(":agenda week"),
757 Ok(CardinalCommand::Agenda(AgendaRange::Week))
758 );
759 assert_eq!(
760 parse_command(":invite tentative"),
761 Ok(CardinalCommand::Invite(InviteCommand::Tentative))
762 );
763 assert_eq!(
764 parse_command(":invite decline"),
765 Ok(CardinalCommand::Invite(InviteCommand::Decline))
766 );
767 }
768
769 #[test]
770 fn parses_event_variants() {
771 assert_eq!(
772 parse_command(":event new"),
773 Ok(CardinalCommand::Event(EventCommand::New))
774 );
775 assert_eq!(
776 parse_command(":event open team"),
777 Ok(CardinalCommand::Event(EventCommand::Open(Selector::Name(
778 "team".into()
779 ))))
780 );
781 assert_eq!(
782 parse_command(":event edit"),
783 Ok(CardinalCommand::Event(EventCommand::Edit(
784 Selector::Current
785 )))
786 );
787 assert_eq!(
788 parse_command(":event edit 4"),
789 Ok(CardinalCommand::Event(EventCommand::Edit(Selector::Index(
790 4
791 ))))
792 );
793 assert_eq!(
794 parse_command(":event delete 7"),
795 Ok(CardinalCommand::Event(EventCommand::Delete(
796 Selector::Index(7)
797 )))
798 );
799 assert_eq!(
800 parse_command(":event move work"),
801 Ok(CardinalCommand::Event(EventCommand::Move {
802 calendar: "work".into()
803 }))
804 );
805 }
806
807 #[test]
808 fn rejects_missing_required_arguments() {
809 assert_eq!(
810 parse_command(":list"),
811 Err(ParseError::MissingArgument("list target"))
812 );
813 assert_eq!(
814 parse_command(":open"),
815 Err(ParseError::MissingArgument("open target"))
816 );
817 assert_eq!(
818 parse_command(":forward"),
819 Err(ParseError::MissingArgument("forward recipient"))
820 );
821 assert_eq!(
822 parse_command(":mark"),
823 Err(ParseError::MissingArgument("mark state"))
824 );
825 assert_eq!(
826 parse_command(":move"),
827 Err(ParseError::MissingArgument("move target"))
828 );
829 assert_eq!(
830 parse_command(":search"),
831 Err(ParseError::MissingArgument("search query"))
832 );
833 assert_eq!(
834 parse_command(":calendar"),
835 Err(ParseError::MissingArgument("calendar view"))
836 );
837 assert_eq!(
838 parse_command(":event"),
839 Err(ParseError::MissingArgument("event subcommand"))
840 );
841 assert_eq!(
842 parse_command(":invite"),
843 Err(ParseError::MissingArgument("invite action"))
844 );
845 assert_eq!(
846 parse_command(":event move"),
847 Err(ParseError::MissingArgument("event move calendar"))
848 );
849 }
850
851 #[test]
852 fn rejects_invalid_arguments() {
853 assert_eq!(
854 parse_command(":list unknown"),
855 Err(ParseError::InvalidArgument {
856 command: "list",
857 argument: "unknown".into(),
858 })
859 );
860 assert_eq!(
861 parse_command(":mark maybe"),
862 Err(ParseError::InvalidArgument {
863 command: "mark",
864 argument: "maybe".into(),
865 })
866 );
867 assert_eq!(
868 parse_command(":calendar year"),
869 Err(ParseError::InvalidArgument {
870 command: "calendar",
871 argument: "year".into(),
872 })
873 );
874 assert_eq!(
875 parse_command(":agenda month"),
876 Err(ParseError::InvalidArgument {
877 command: "agenda",
878 argument: "month".into(),
879 })
880 );
881 assert_eq!(
882 parse_command(":event explode"),
883 Err(ParseError::InvalidArgument {
884 command: "event",
885 argument: "explode".into(),
886 })
887 );
888 assert_eq!(
889 parse_command(":invite maybe"),
890 Err(ParseError::InvalidArgument {
891 command: "invite",
892 argument: "maybe".into(),
893 })
894 );
895 assert_eq!(
896 parse_command(":sync now"),
897 Err(ParseError::InvalidArgument {
898 command: "sync",
899 argument: "now".into(),
900 })
901 );
902 assert_eq!(
903 parse_command(":send later"),
904 Err(ParseError::InvalidArgument {
905 command: "send",
906 argument: "later".into(),
907 })
908 );
909 }
910
911 #[test]
912 fn rejects_unexpected_arguments_for_fixed_arity_commands() {
913 assert_eq!(
914 parse_command(":list inboxes extra"),
915 Err(ParseError::UnexpectedArgument {
916 command: "list",
917 argument: "extra".into(),
918 })
919 );
920 assert_eq!(
921 parse_command(":reply all extra"),
922 Err(ParseError::UnexpectedArgument {
923 command: "reply",
924 argument: "extra".into(),
925 })
926 );
927 assert_eq!(
928 parse_command(":calendar week extra"),
929 Err(ParseError::UnexpectedArgument {
930 command: "calendar",
931 argument: "extra".into(),
932 })
933 );
934 assert_eq!(
935 parse_command(":agenda today extra"),
936 Err(ParseError::UnexpectedArgument {
937 command: "agenda",
938 argument: "extra".into(),
939 })
940 );
941 assert_eq!(
942 parse_command(":event new extra"),
943 Err(ParseError::UnexpectedArgument {
944 command: "event new",
945 argument: "extra".into(),
946 })
947 );
948 assert_eq!(
949 parse_command(":event move work extra"),
950 Err(ParseError::UnexpectedArgument {
951 command: "event move",
952 argument: "extra".into(),
953 })
954 );
955 assert_eq!(
956 parse_command(":invite accept extra"),
957 Err(ParseError::UnexpectedArgument {
958 command: "invite",
959 argument: "extra".into(),
960 })
961 );
962 assert_eq!(
963 parse_command(":sync mail extra"),
964 Err(ParseError::UnexpectedArgument {
965 command: "sync",
966 argument: "extra".into(),
967 })
968 );
969 assert_eq!(
970 parse_command(":send confirm now"),
971 Err(ParseError::UnexpectedArgument {
972 command: "send",
973 argument: "now".into(),
974 })
975 );
976 assert_eq!(
977 parse_command(":undo now"),
978 Err(ParseError::UnexpectedArgument {
979 command: "undo",
980 argument: "now".into(),
981 })
982 );
983 }
984
985 #[test]
986 fn parse_event_edit_and_delete_accept_name_selector() {
987 assert_eq!(
988 parse_command(":event edit current"),
989 Ok(CardinalCommand::Event(EventCommand::Edit(Selector::Name(
990 "current".into()
991 ))))
992 );
993 assert_eq!(
994 parse_command(":event delete selected"),
995 Ok(CardinalCommand::Event(EventCommand::Delete(
996 Selector::Name("selected".into())
997 )))
998 );
999 }
1000
1001 #[test]
1002 fn tokenize_handles_trailing_escape() {
1003 assert_eq!(
1004 parse_command(":search path\\"),
1005 Ok(CardinalCommand::Search {
1006 query: "path\\".into()
1007 })
1008 );
1009 }
1010
1011 #[test]
1012 fn rejects_unterminated_quote() {
1013 assert_eq!(
1014 parse_command(":search \"broken"),
1015 Err(ParseError::UnterminatedQuote)
1016 );
1017 }
1018}