use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CardinalCommand {
List(ListTarget),
Open(Selector),
Compose,
Reply { all: bool },
Forward { recipient: String },
Archive,
Delete,
Spam,
Mark(MarkState),
Move { target: String },
Send { confirm: bool },
Search { query: String },
Calendar(CalendarView),
Agenda(AgendaRange),
Event(EventCommand),
Invite(InviteCommand),
Sync(SyncTarget),
Undo,
Help,
Bindings,
Config,
Reload,
Quit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListTarget {
Inboxes,
Folders,
Mail,
Unread,
Flagged,
Calendars,
Invites,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Selector {
Index(usize),
Name(String),
Current,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarkState {
Read,
Unread,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CalendarView {
Today,
Tomorrow,
Week,
Month,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgendaRange {
Default,
Today,
Tomorrow,
Week,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventCommand {
New,
Open(Selector),
Edit(Selector),
Delete(Selector),
Duplicate(Selector),
Move { calendar: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InviteCommand {
Accept,
Tentative,
Decline,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncTarget {
All,
Mail,
Calendar,
Contacts,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ParseError {
#[error("empty command")]
Empty,
#[error("unknown command: {0}")]
UnknownCommand(String),
#[error("missing argument: {0}")]
MissingArgument(&'static str),
#[error("invalid argument for {command}: {argument}")]
InvalidArgument {
command: &'static str,
argument: String,
},
#[error("unexpected argument for {command}: {argument}")]
UnexpectedArgument {
command: &'static str,
argument: String,
},
#[error("unterminated quoted string")]
UnterminatedQuote,
}
pub fn parse_command(input: &str) -> Result<CardinalCommand, ParseError> {
let trimmed = input.trim();
let body = trimmed.strip_prefix(':').unwrap_or(trimmed).trim();
if body.is_empty() {
return Err(ParseError::Empty);
}
let tokens = tokenize(body)?;
if tokens.is_empty() {
return Err(ParseError::Empty);
}
match tokens[0].as_str() {
"list" => parse_list(&tokens),
"open" => parse_open(&tokens),
"compose" => Ok(CardinalCommand::Compose),
"reply" => parse_reply(&tokens),
"forward" => parse_forward(&tokens),
"archive" => Ok(CardinalCommand::Archive),
"delete" => Ok(CardinalCommand::Delete),
"spam" | "spamit" => Ok(CardinalCommand::Spam),
"mark" => parse_mark(&tokens),
"move" => parse_move(&tokens),
"send" => parse_send(&tokens),
"search" => parse_search(&tokens),
"calendar" => parse_calendar(&tokens),
"agenda" => parse_agenda(&tokens),
"event" => parse_event(&tokens),
"invite" => parse_invite(&tokens),
"sync" => parse_sync(&tokens),
"undo" => {
reject_extra_arguments(&tokens, 1, "undo")?;
Ok(CardinalCommand::Undo)
}
"help" => Ok(CardinalCommand::Help),
"bindings" => Ok(CardinalCommand::Bindings),
"config" => Ok(CardinalCommand::Config),
"reload" => Ok(CardinalCommand::Reload),
"quit" | "q" => Ok(CardinalCommand::Quit),
other => Err(ParseError::UnknownCommand(other.to_owned())),
}
}
fn parse_list(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let target = required(tokens, 1, "list target")?;
reject_extra_arguments(tokens, 2, "list")?;
let target = match target.as_str() {
"inboxes" => ListTarget::Inboxes,
"folders" => ListTarget::Folders,
"mail" => ListTarget::Mail,
"unread" => ListTarget::Unread,
"flagged" => ListTarget::Flagged,
"calendars" => ListTarget::Calendars,
"invites" => ListTarget::Invites,
other => {
return Err(ParseError::InvalidArgument {
command: "list",
argument: other.to_owned(),
})
}
};
Ok(CardinalCommand::List(target))
}
fn parse_open(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let target = required(tokens, 1, "open target")?;
reject_extra_arguments(tokens, 2, "open")?;
Ok(CardinalCommand::Open(parse_selector(target)))
}
fn parse_reply(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let all = match tokens.get(1).map(String::as_str) {
None => false,
Some("all") => true,
Some(other) => {
return Err(ParseError::InvalidArgument {
command: "reply",
argument: other.to_owned(),
})
}
};
reject_extra_arguments(tokens, 2, "reply")?;
Ok(CardinalCommand::Reply { all })
}
fn parse_forward(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let recipient = required(tokens, 1, "forward recipient")?.to_owned();
reject_extra_arguments(tokens, 2, "forward")?;
Ok(CardinalCommand::Forward { recipient })
}
fn parse_mark(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let state = match required(tokens, 1, "mark state")?.as_str() {
"read" => MarkState::Read,
"unread" => MarkState::Unread,
other => {
return Err(ParseError::InvalidArgument {
command: "mark",
argument: other.to_owned(),
})
}
};
reject_extra_arguments(tokens, 2, "mark")?;
Ok(CardinalCommand::Mark(state))
}
fn parse_move(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let target = required(tokens, 1, "move target")?.to_owned();
reject_extra_arguments(tokens, 2, "move")?;
Ok(CardinalCommand::Move { target })
}
fn parse_search(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
if tokens.len() < 2 {
return Err(ParseError::MissingArgument("search query"));
}
Ok(CardinalCommand::Search {
query: tokens[1..].join(" "),
})
}
fn parse_send(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
reject_extra_arguments(tokens, 2, "send")?;
let confirm = match tokens.get(1).map(String::as_str) {
None => false,
Some("confirm") => true,
Some(other) => {
return Err(ParseError::InvalidArgument {
command: "send",
argument: other.to_owned(),
})
}
};
Ok(CardinalCommand::Send { confirm })
}
fn parse_calendar(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let view = match required(tokens, 1, "calendar view")?.as_str() {
"today" => CalendarView::Today,
"tomorrow" => CalendarView::Tomorrow,
"week" => CalendarView::Week,
"month" => CalendarView::Month,
other => {
return Err(ParseError::InvalidArgument {
command: "calendar",
argument: other.to_owned(),
})
}
};
reject_extra_arguments(tokens, 2, "calendar")?;
Ok(CardinalCommand::Calendar(view))
}
fn parse_agenda(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
reject_extra_arguments(tokens, 2, "agenda")?;
let range = match tokens.get(1).map(String::as_str) {
None => AgendaRange::Default,
Some("today") => AgendaRange::Today,
Some("tomorrow") => AgendaRange::Tomorrow,
Some("week") => AgendaRange::Week,
Some(other) => {
return Err(ParseError::InvalidArgument {
command: "agenda",
argument: other.to_owned(),
})
}
};
Ok(CardinalCommand::Agenda(range))
}
fn parse_event(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let subcommand = required(tokens, 1, "event subcommand")?;
let command = match subcommand.as_str() {
"new" => {
reject_extra_arguments(tokens, 2, "event new")?;
EventCommand::New
}
"open" => {
let selector = parse_selector(required(tokens, 2, "event open selector")?);
reject_extra_arguments(tokens, 3, "event open")?;
EventCommand::Open(selector)
}
"edit" => {
reject_extra_arguments(tokens, 3, "event edit")?;
EventCommand::Edit(parse_optional_selector(tokens.get(2).map(String::as_str)))
}
"delete" => {
reject_extra_arguments(tokens, 3, "event delete")?;
EventCommand::Delete(parse_optional_selector(tokens.get(2).map(String::as_str)))
}
"duplicate" => {
reject_extra_arguments(tokens, 2, "event duplicate")?;
EventCommand::Duplicate(Selector::Current)
}
"move" => {
let calendar = required(tokens, 2, "event move calendar")?.to_owned();
reject_extra_arguments(tokens, 3, "event move")?;
EventCommand::Move { calendar }
}
other => {
return Err(ParseError::InvalidArgument {
command: "event",
argument: other.to_owned(),
})
}
};
Ok(CardinalCommand::Event(command))
}
fn parse_invite(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
let command = match required(tokens, 1, "invite action")?.as_str() {
"accept" => InviteCommand::Accept,
"tentative" => InviteCommand::Tentative,
"decline" => InviteCommand::Decline,
other => {
return Err(ParseError::InvalidArgument {
command: "invite",
argument: other.to_owned(),
})
}
};
reject_extra_arguments(tokens, 2, "invite")?;
Ok(CardinalCommand::Invite(command))
}
fn parse_sync(tokens: &[String]) -> Result<CardinalCommand, ParseError> {
reject_extra_arguments(tokens, 2, "sync")?;
let target = match tokens.get(1).map(String::as_str) {
None => SyncTarget::All,
Some("mail") => SyncTarget::Mail,
Some("calendar") => SyncTarget::Calendar,
Some("contacts") => SyncTarget::Contacts,
Some(other) => {
return Err(ParseError::InvalidArgument {
command: "sync",
argument: other.to_owned(),
})
}
};
Ok(CardinalCommand::Sync(target))
}
fn parse_selector(value: &str) -> Selector {
match value.parse::<usize>() {
Ok(index) => Selector::Index(index),
Err(_) => Selector::Name(value.to_owned()),
}
}
fn parse_optional_selector(value: Option<&str>) -> Selector {
match value {
Some(value) => parse_selector(value),
None => Selector::Current,
}
}
fn required<'a>(
tokens: &'a [String],
index: usize,
name: &'static str,
) -> Result<&'a String, ParseError> {
tokens.get(index).ok_or(ParseError::MissingArgument(name))
}
fn reject_extra_arguments(
tokens: &[String],
expected_len: usize,
command: &'static str,
) -> Result<(), ParseError> {
if let Some(argument) = tokens.get(expected_len) {
return Err(ParseError::UnexpectedArgument {
command,
argument: argument.to_owned(),
});
}
Ok(())
}
fn tokenize(input: &str) -> Result<Vec<String>, ParseError> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut in_quotes = false;
while let Some(ch) = chars.next() {
match ch {
'"' => in_quotes = !in_quotes,
'\\' => match chars.next() {
Some(next) => current.push(next),
None => current.push('\\'),
},
c if c.is_whitespace() && !in_quotes => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if in_quotes {
return Err(ParseError::UnterminatedQuote);
}
if !current.is_empty() {
tokens.push(current);
}
Ok(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_list_inboxes() {
assert_eq!(
parse_command(":list inboxes"),
Ok(CardinalCommand::List(ListTarget::Inboxes))
);
}
#[test]
fn parses_open_index() {
assert_eq!(
parse_command(":open 4"),
Ok(CardinalCommand::Open(Selector::Index(4)))
);
}
#[test]
fn parses_open_name() {
assert_eq!(
parse_command(":open personal"),
Ok(CardinalCommand::Open(Selector::Name("personal".into())))
);
}
#[test]
fn parses_reply_all() {
assert_eq!(
parse_command(":reply all"),
Ok(CardinalCommand::Reply { all: true })
);
}
#[test]
fn parses_reply_without_all() {
assert_eq!(
parse_command(":reply"),
Ok(CardinalCommand::Reply { all: false })
);
}
#[test]
fn parses_spamit_alias() {
assert_eq!(parse_command(":spamit"), Ok(CardinalCommand::Spam));
}
#[test]
fn parses_calendar_week() {
assert_eq!(
parse_command(":calendar week"),
Ok(CardinalCommand::Calendar(CalendarView::Week))
);
}
#[test]
fn parses_agenda_default() {
assert_eq!(
parse_command(":agenda"),
Ok(CardinalCommand::Agenda(AgendaRange::Default))
);
}
#[test]
fn parses_event_delete_current() {
assert_eq!(
parse_command(":event delete"),
Ok(CardinalCommand::Event(EventCommand::Delete(
Selector::Current
)))
);
}
#[test]
fn parses_event_open_index() {
assert_eq!(
parse_command(":event open 2"),
Ok(CardinalCommand::Event(EventCommand::Open(Selector::Index(
2
))))
);
}
#[test]
fn parses_event_duplicate_current() {
assert_eq!(
parse_command(":event duplicate"),
Ok(CardinalCommand::Event(EventCommand::Duplicate(
Selector::Current
)))
);
}
#[test]
fn parses_invite_accept() {
assert_eq!(
parse_command(":invite accept"),
Ok(CardinalCommand::Invite(InviteCommand::Accept))
);
}
#[test]
fn parses_quoted_search_query() {
assert_eq!(
parse_command(":search \"hello world\""),
Ok(CardinalCommand::Search {
query: "hello world".into()
})
);
}
#[test]
fn rejects_empty_command() {
assert_eq!(parse_command(":"), Err(ParseError::Empty));
}
#[test]
fn rejects_unknown_command() {
assert_eq!(
parse_command(":explode"),
Err(ParseError::UnknownCommand("explode".into()))
);
}
#[test]
fn rejects_undocumented_aliases() {
assert_eq!(
parse_command(":ls inboxes"),
Err(ParseError::UnknownCommand("ls".into()))
);
assert_eq!(
parse_command(":cal week"),
Err(ParseError::UnknownCommand("cal".into()))
);
assert_eq!(
parse_command(":/ query"),
Err(ParseError::UnknownCommand("/".into()))
);
}
#[test]
fn rejects_reply_invalid_argument() {
assert_eq!(
parse_command(":reply team"),
Err(ParseError::InvalidArgument {
command: "reply",
argument: "team".into(),
})
);
}
#[test]
fn rejects_extra_argument_after_open() {
assert_eq!(
parse_command(":open 2 extra"),
Err(ParseError::UnexpectedArgument {
command: "open",
argument: "extra".into(),
})
);
}
#[test]
fn rejects_extra_argument_after_event_edit() {
assert_eq!(
parse_command(":event edit 1 extra"),
Err(ParseError::UnexpectedArgument {
command: "event edit",
argument: "extra".into(),
})
);
}
#[test]
fn rejects_missing_event_open_selector() {
assert_eq!(
parse_command(":event open"),
Err(ParseError::MissingArgument("event open selector"))
);
}
#[test]
fn rejects_event_duplicate_selector() {
assert_eq!(
parse_command(":event duplicate 2"),
Err(ParseError::UnexpectedArgument {
command: "event duplicate",
argument: "2".into(),
})
);
}
#[test]
fn parses_command_without_colon_and_with_whitespace() {
assert_eq!(
parse_command(" list mail "),
Ok(CardinalCommand::List(ListTarget::Mail))
);
}
#[test]
fn parses_help_bindings_config_reload_and_quit_alias() {
assert_eq!(parse_command(":help"), Ok(CardinalCommand::Help));
assert_eq!(parse_command(":bindings"), Ok(CardinalCommand::Bindings));
assert_eq!(parse_command(":config"), Ok(CardinalCommand::Config));
assert_eq!(parse_command(":reload"), Ok(CardinalCommand::Reload));
assert_eq!(parse_command(":undo"), Ok(CardinalCommand::Undo));
assert_eq!(parse_command(":quit"), Ok(CardinalCommand::Quit));
assert_eq!(parse_command(":q"), Ok(CardinalCommand::Quit));
}
#[test]
fn parses_compose_archive_delete_and_sync_variants() {
assert_eq!(parse_command(":compose"), Ok(CardinalCommand::Compose));
assert_eq!(parse_command(":archive"), Ok(CardinalCommand::Archive));
assert_eq!(parse_command(":delete"), Ok(CardinalCommand::Delete));
assert_eq!(
parse_command(":send"),
Ok(CardinalCommand::Send { confirm: false })
);
assert_eq!(
parse_command(":send confirm"),
Ok(CardinalCommand::Send { confirm: true })
);
assert_eq!(
parse_command(":sync"),
Ok(CardinalCommand::Sync(SyncTarget::All))
);
assert_eq!(
parse_command(":sync mail"),
Ok(CardinalCommand::Sync(SyncTarget::Mail))
);
assert_eq!(
parse_command(":sync calendar"),
Ok(CardinalCommand::Sync(SyncTarget::Calendar))
);
assert_eq!(
parse_command(":sync contacts"),
Ok(CardinalCommand::Sync(SyncTarget::Contacts))
);
}
#[test]
fn parses_mark_and_move_and_forward() {
assert_eq!(
parse_command(":mark read"),
Ok(CardinalCommand::Mark(MarkState::Read))
);
assert_eq!(
parse_command(":mark unread"),
Ok(CardinalCommand::Mark(MarkState::Unread))
);
assert_eq!(
parse_command(":move archive"),
Ok(CardinalCommand::Move {
target: "archive".into()
})
);
assert_eq!(
parse_command(":forward alice@example.com"),
Ok(CardinalCommand::Forward {
recipient: "alice@example.com".into()
})
);
}
#[test]
fn parses_list_variants() {
assert_eq!(
parse_command(":list folders"),
Ok(CardinalCommand::List(ListTarget::Folders))
);
assert_eq!(
parse_command(":list unread"),
Ok(CardinalCommand::List(ListTarget::Unread))
);
assert_eq!(
parse_command(":list flagged"),
Ok(CardinalCommand::List(ListTarget::Flagged))
);
assert_eq!(
parse_command(":list calendars"),
Ok(CardinalCommand::List(ListTarget::Calendars))
);
assert_eq!(
parse_command(":list invites"),
Ok(CardinalCommand::List(ListTarget::Invites))
);
}
#[test]
fn parses_search_with_multiple_tokens_and_escaped_quote() {
assert_eq!(
parse_command(":search from:alice subject:invoice"),
Ok(CardinalCommand::Search {
query: "from:alice subject:invoice".into()
})
);
assert_eq!(
parse_command(":search \"hello \\\"team\\\"\""),
Ok(CardinalCommand::Search {
query: "hello \"team\"".into()
})
);
}
#[test]
fn parses_agenda_and_invite_variants() {
assert_eq!(
parse_command(":agenda today"),
Ok(CardinalCommand::Agenda(AgendaRange::Today))
);
assert_eq!(
parse_command(":agenda tomorrow"),
Ok(CardinalCommand::Agenda(AgendaRange::Tomorrow))
);
assert_eq!(
parse_command(":agenda week"),
Ok(CardinalCommand::Agenda(AgendaRange::Week))
);
assert_eq!(
parse_command(":invite tentative"),
Ok(CardinalCommand::Invite(InviteCommand::Tentative))
);
assert_eq!(
parse_command(":invite decline"),
Ok(CardinalCommand::Invite(InviteCommand::Decline))
);
}
#[test]
fn parses_event_variants() {
assert_eq!(
parse_command(":event new"),
Ok(CardinalCommand::Event(EventCommand::New))
);
assert_eq!(
parse_command(":event open team"),
Ok(CardinalCommand::Event(EventCommand::Open(Selector::Name(
"team".into()
))))
);
assert_eq!(
parse_command(":event edit"),
Ok(CardinalCommand::Event(EventCommand::Edit(
Selector::Current
)))
);
assert_eq!(
parse_command(":event edit 4"),
Ok(CardinalCommand::Event(EventCommand::Edit(Selector::Index(
4
))))
);
assert_eq!(
parse_command(":event delete 7"),
Ok(CardinalCommand::Event(EventCommand::Delete(
Selector::Index(7)
)))
);
assert_eq!(
parse_command(":event move work"),
Ok(CardinalCommand::Event(EventCommand::Move {
calendar: "work".into()
}))
);
}
#[test]
fn rejects_missing_required_arguments() {
assert_eq!(
parse_command(":list"),
Err(ParseError::MissingArgument("list target"))
);
assert_eq!(
parse_command(":open"),
Err(ParseError::MissingArgument("open target"))
);
assert_eq!(
parse_command(":forward"),
Err(ParseError::MissingArgument("forward recipient"))
);
assert_eq!(
parse_command(":mark"),
Err(ParseError::MissingArgument("mark state"))
);
assert_eq!(
parse_command(":move"),
Err(ParseError::MissingArgument("move target"))
);
assert_eq!(
parse_command(":search"),
Err(ParseError::MissingArgument("search query"))
);
assert_eq!(
parse_command(":calendar"),
Err(ParseError::MissingArgument("calendar view"))
);
assert_eq!(
parse_command(":event"),
Err(ParseError::MissingArgument("event subcommand"))
);
assert_eq!(
parse_command(":invite"),
Err(ParseError::MissingArgument("invite action"))
);
assert_eq!(
parse_command(":event move"),
Err(ParseError::MissingArgument("event move calendar"))
);
}
#[test]
fn rejects_invalid_arguments() {
assert_eq!(
parse_command(":list unknown"),
Err(ParseError::InvalidArgument {
command: "list",
argument: "unknown".into(),
})
);
assert_eq!(
parse_command(":mark maybe"),
Err(ParseError::InvalidArgument {
command: "mark",
argument: "maybe".into(),
})
);
assert_eq!(
parse_command(":calendar year"),
Err(ParseError::InvalidArgument {
command: "calendar",
argument: "year".into(),
})
);
assert_eq!(
parse_command(":agenda month"),
Err(ParseError::InvalidArgument {
command: "agenda",
argument: "month".into(),
})
);
assert_eq!(
parse_command(":event explode"),
Err(ParseError::InvalidArgument {
command: "event",
argument: "explode".into(),
})
);
assert_eq!(
parse_command(":invite maybe"),
Err(ParseError::InvalidArgument {
command: "invite",
argument: "maybe".into(),
})
);
assert_eq!(
parse_command(":sync now"),
Err(ParseError::InvalidArgument {
command: "sync",
argument: "now".into(),
})
);
assert_eq!(
parse_command(":send later"),
Err(ParseError::InvalidArgument {
command: "send",
argument: "later".into(),
})
);
}
#[test]
fn rejects_unexpected_arguments_for_fixed_arity_commands() {
assert_eq!(
parse_command(":list inboxes extra"),
Err(ParseError::UnexpectedArgument {
command: "list",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":reply all extra"),
Err(ParseError::UnexpectedArgument {
command: "reply",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":calendar week extra"),
Err(ParseError::UnexpectedArgument {
command: "calendar",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":agenda today extra"),
Err(ParseError::UnexpectedArgument {
command: "agenda",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":event new extra"),
Err(ParseError::UnexpectedArgument {
command: "event new",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":event move work extra"),
Err(ParseError::UnexpectedArgument {
command: "event move",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":invite accept extra"),
Err(ParseError::UnexpectedArgument {
command: "invite",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":sync mail extra"),
Err(ParseError::UnexpectedArgument {
command: "sync",
argument: "extra".into(),
})
);
assert_eq!(
parse_command(":send confirm now"),
Err(ParseError::UnexpectedArgument {
command: "send",
argument: "now".into(),
})
);
assert_eq!(
parse_command(":undo now"),
Err(ParseError::UnexpectedArgument {
command: "undo",
argument: "now".into(),
})
);
}
#[test]
fn parse_event_edit_and_delete_accept_name_selector() {
assert_eq!(
parse_command(":event edit current"),
Ok(CardinalCommand::Event(EventCommand::Edit(Selector::Name(
"current".into()
))))
);
assert_eq!(
parse_command(":event delete selected"),
Ok(CardinalCommand::Event(EventCommand::Delete(
Selector::Name("selected".into())
)))
);
}
#[test]
fn tokenize_handles_trailing_escape() {
assert_eq!(
parse_command(":search path\\"),
Ok(CardinalCommand::Search {
query: "path\\".into()
})
);
}
#[test]
fn rejects_unterminated_quote() {
assert_eq!(
parse_command(":search \"broken"),
Err(ParseError::UnterminatedQuote)
);
}
}