Skip to main content

cardinal_core/
command.rs

1use thiserror::Error;
2
3/// A parsed Cardinal command.
4///
5/// Commands represent user intent. They do not directly perform filesystem,
6/// network, or terminal effects.
7#[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
121/// Parse a Cardinal command line.
122///
123/// The leading `:` is optional so that the parser remains convenient in tests
124/// and non-interactive CLI tooling.
125pub 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}