cardinal-app-core 0.1.4

Core command grammar and domain model for Cardinal.
Documentation
use crate::command::{AgendaRange, CalendarView, CardinalCommand, ListTarget, SyncTarget};
use crate::workspace::{View, WorkspaceState};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DomainEffectRequest {
    ReadMaildir,
    MoveMessage,
    SendMail,
    ReadCalendar,
    WriteEvent,
    RunSync(SyncTarget),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutcome {
    pub state: WorkspaceState,
    pub effect: Option<DomainEffectRequest>,
    pub status_message: Option<String>,
}

pub fn dispatch_command(state: &WorkspaceState, command: &CardinalCommand) -> CommandOutcome {
    let mut next_state = state.clone();
    let mut effect = None;
    let mut status_message = None;

    match command {
        CardinalCommand::List(target) => match target {
            ListTarget::Inboxes | ListTarget::Folders => next_state.set_view(View::Inboxes),
            ListTarget::Mail => {
                next_state.set_view(View::MailList {
                    mailbox: "mail".to_owned(),
                });
                effect = Some(DomainEffectRequest::ReadMaildir);
            }
            ListTarget::Unread => {
                next_state.set_view(View::MailList {
                    mailbox: "unread".to_owned(),
                });
                effect = Some(DomainEffectRequest::ReadMaildir);
            }
            ListTarget::Flagged => {
                next_state.set_view(View::MailList {
                    mailbox: "flagged".to_owned(),
                });
                effect = Some(DomainEffectRequest::ReadMaildir);
            }
            ListTarget::Calendars => {
                next_state.set_view(View::Calendars);
                effect = Some(DomainEffectRequest::ReadCalendar);
            }
            ListTarget::Invites => {
                next_state.set_view(View::MailList {
                    mailbox: "invites".to_owned(),
                });
                effect = Some(DomainEffectRequest::ReadMaildir);
            }
        },
        CardinalCommand::Calendar(view) => {
            let range = match view {
                CalendarView::Today => AgendaRange::Today,
                CalendarView::Tomorrow => AgendaRange::Tomorrow,
                CalendarView::Week => AgendaRange::Week,
                CalendarView::Month => AgendaRange::Default,
            };
            next_state.set_view(View::Agenda { range });
            effect = Some(DomainEffectRequest::ReadCalendar);
        }
        CardinalCommand::Agenda(range) => {
            next_state.set_view(View::Agenda {
                range: range.clone(),
            });
            effect = Some(DomainEffectRequest::ReadCalendar);
        }
        CardinalCommand::Compose
        | CardinalCommand::Reply { .. }
        | CardinalCommand::Forward { .. } => {
            next_state.set_view(View::Compose);
        }
        CardinalCommand::Send { .. } => {
            effect = Some(DomainEffectRequest::SendMail);
        }
        CardinalCommand::Search { query } => {
            next_state.set_view(View::SearchResults {
                query: query.clone(),
            });
        }
        CardinalCommand::Archive
        | CardinalCommand::Delete
        | CardinalCommand::Spam
        | CardinalCommand::Mark(_)
        | CardinalCommand::Move { .. }
        | CardinalCommand::Undo => {
            effect = Some(DomainEffectRequest::MoveMessage);
        }
        CardinalCommand::Event(_) | CardinalCommand::Invite(_) => {
            effect = Some(DomainEffectRequest::WriteEvent);
        }
        CardinalCommand::Sync(target) => {
            effect = Some(DomainEffectRequest::RunSync(target.clone()));
            status_message = Some("sync queued".to_owned());
        }
        CardinalCommand::Open(_)
        | CardinalCommand::Help
        | CardinalCommand::Bindings
        | CardinalCommand::Config
        | CardinalCommand::Reload
        | CardinalCommand::Quit => {}
    }

    CommandOutcome {
        state: next_state,
        effect,
        status_message,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::command::{CardinalCommand, EventCommand, InviteCommand, MarkState, Selector};

    #[test]
    fn dispatches_agenda_command_to_typed_view() {
        let state = WorkspaceState::default();
        let outcome = dispatch_command(&state, &CardinalCommand::Agenda(AgendaRange::Week));

        assert_eq!(
            outcome.state.view,
            View::Agenda {
                range: AgendaRange::Week
            }
        );
        assert_eq!(outcome.effect, Some(DomainEffectRequest::ReadCalendar));
    }

    #[test]
    fn dispatches_sync_command_to_effect() {
        let state = WorkspaceState::default();
        let outcome = dispatch_command(&state, &CardinalCommand::Sync(SyncTarget::Mail));

        assert_eq!(
            outcome.effect,
            Some(DomainEffectRequest::RunSync(SyncTarget::Mail))
        );
        assert_eq!(outcome.status_message, Some("sync queued".to_owned()));
    }

    #[test]
    fn dispatches_list_calendars_to_calendar_view() {
        let state = WorkspaceState::default();
        let outcome = dispatch_command(&state, &CardinalCommand::List(ListTarget::Calendars));

        assert_eq!(outcome.state.view, View::Calendars);
        assert_eq!(outcome.effect, Some(DomainEffectRequest::ReadCalendar));
    }

    #[test]
    fn dispatches_archive_to_mail_move_effect() {
        let state = WorkspaceState::default();
        let outcome = dispatch_command(&state, &CardinalCommand::Archive);

        assert_eq!(outcome.effect, Some(DomainEffectRequest::MoveMessage));
    }

    #[test]
    fn dispatches_mail_list_targets_to_maildir_read() {
        let state = WorkspaceState::default();
        let list_mail = dispatch_command(&state, &CardinalCommand::List(ListTarget::Mail));
        let list_unread = dispatch_command(&state, &CardinalCommand::List(ListTarget::Unread));
        let list_flagged = dispatch_command(&state, &CardinalCommand::List(ListTarget::Flagged));
        let list_invites = dispatch_command(&state, &CardinalCommand::List(ListTarget::Invites));

        assert_eq!(
            list_mail.state.view,
            View::MailList {
                mailbox: "mail".to_owned()
            }
        );
        assert_eq!(
            list_unread.state.view,
            View::MailList {
                mailbox: "unread".to_owned()
            }
        );
        assert_eq!(
            list_flagged.state.view,
            View::MailList {
                mailbox: "flagged".to_owned()
            }
        );
        assert_eq!(
            list_invites.state.view,
            View::MailList {
                mailbox: "invites".to_owned()
            }
        );
        assert_eq!(list_mail.effect, Some(DomainEffectRequest::ReadMaildir));
        assert_eq!(list_unread.effect, Some(DomainEffectRequest::ReadMaildir));
        assert_eq!(list_flagged.effect, Some(DomainEffectRequest::ReadMaildir));
        assert_eq!(list_invites.effect, Some(DomainEffectRequest::ReadMaildir));
    }

    #[test]
    fn dispatches_non_mail_list_targets_without_maildir_effect() {
        let state = WorkspaceState::default();
        let list_inboxes = dispatch_command(&state, &CardinalCommand::List(ListTarget::Inboxes));
        let list_folders = dispatch_command(&state, &CardinalCommand::List(ListTarget::Folders));

        assert_eq!(list_inboxes.state.view, View::Inboxes);
        assert_eq!(list_folders.state.view, View::Inboxes);
        assert_eq!(list_inboxes.effect, None);
        assert_eq!(list_folders.effect, None);
    }

    #[test]
    fn dispatches_calendar_views_to_agenda_ranges() {
        let state = WorkspaceState::default();
        let today = dispatch_command(&state, &CardinalCommand::Calendar(CalendarView::Today));
        let tomorrow = dispatch_command(&state, &CardinalCommand::Calendar(CalendarView::Tomorrow));
        let week = dispatch_command(&state, &CardinalCommand::Calendar(CalendarView::Week));
        let month = dispatch_command(&state, &CardinalCommand::Calendar(CalendarView::Month));

        assert_eq!(
            today.state.view,
            View::Agenda {
                range: AgendaRange::Today
            }
        );
        assert_eq!(
            tomorrow.state.view,
            View::Agenda {
                range: AgendaRange::Tomorrow
            }
        );
        assert_eq!(
            week.state.view,
            View::Agenda {
                range: AgendaRange::Week
            }
        );
        assert_eq!(
            month.state.view,
            View::Agenda {
                range: AgendaRange::Default
            }
        );
        assert_eq!(today.effect, Some(DomainEffectRequest::ReadCalendar));
        assert_eq!(tomorrow.effect, Some(DomainEffectRequest::ReadCalendar));
        assert_eq!(week.effect, Some(DomainEffectRequest::ReadCalendar));
        assert_eq!(month.effect, Some(DomainEffectRequest::ReadCalendar));
    }

    #[test]
    fn dispatches_compose_reply_and_forward_to_compose_view() {
        let state = WorkspaceState::default();
        let compose = dispatch_command(&state, &CardinalCommand::Compose);
        let reply = dispatch_command(&state, &CardinalCommand::Reply { all: true });
        let forward = dispatch_command(
            &state,
            &CardinalCommand::Forward {
                recipient: "a@example.com".to_owned(),
            },
        );

        assert_eq!(compose.state.view, View::Compose);
        assert_eq!(reply.state.view, View::Compose);
        assert_eq!(forward.state.view, View::Compose);
    }

    #[test]
    fn dispatches_send_to_sendmail_effect() {
        let state = WorkspaceState::default();
        let send = dispatch_command(&state, &CardinalCommand::Send { confirm: false });
        let confirm = dispatch_command(&state, &CardinalCommand::Send { confirm: true });

        assert_eq!(send.effect, Some(DomainEffectRequest::SendMail));
        assert_eq!(confirm.effect, Some(DomainEffectRequest::SendMail));
        assert_eq!(send.state, state);
    }

    #[test]
    fn dispatches_search_to_search_results_view() {
        let state = WorkspaceState::default();
        let outcome = dispatch_command(
            &state,
            &CardinalCommand::Search {
                query: "from:alice".to_owned(),
            },
        );

        assert_eq!(
            outcome.state.view,
            View::SearchResults {
                query: "from:alice".to_owned()
            }
        );
    }

    #[test]
    fn dispatches_all_mail_mutations_to_move_message_effect() {
        let state = WorkspaceState::default();
        let delete = dispatch_command(&state, &CardinalCommand::Delete);
        let spam = dispatch_command(&state, &CardinalCommand::Spam);
        let mark = dispatch_command(&state, &CardinalCommand::Mark(MarkState::Read));
        let mv = dispatch_command(
            &state,
            &CardinalCommand::Move {
                target: "archive".to_owned(),
            },
        );
        let undo = dispatch_command(&state, &CardinalCommand::Undo);

        assert_eq!(delete.effect, Some(DomainEffectRequest::MoveMessage));
        assert_eq!(spam.effect, Some(DomainEffectRequest::MoveMessage));
        assert_eq!(mark.effect, Some(DomainEffectRequest::MoveMessage));
        assert_eq!(mv.effect, Some(DomainEffectRequest::MoveMessage));
        assert_eq!(undo.effect, Some(DomainEffectRequest::MoveMessage));
    }

    #[test]
    fn dispatches_noop_commands_without_side_effects() {
        let state = WorkspaceState::default();
        let open = dispatch_command(&state, &CardinalCommand::Open(Selector::Index(1)));
        let help = dispatch_command(&state, &CardinalCommand::Help);
        let bindings = dispatch_command(&state, &CardinalCommand::Bindings);
        let config = dispatch_command(&state, &CardinalCommand::Config);
        let reload = dispatch_command(&state, &CardinalCommand::Reload);
        let quit = dispatch_command(&state, &CardinalCommand::Quit);

        assert_eq!(open.effect, None);
        assert_eq!(help.effect, None);
        assert_eq!(bindings.effect, None);
        assert_eq!(config.effect, None);
        assert_eq!(reload.effect, None);
        assert_eq!(quit.effect, None);
        assert_eq!(open.state, state);
        assert_eq!(quit.state, state);
    }

    #[test]
    fn dispatches_event_and_invite_to_calendar_write_effect() {
        let state = WorkspaceState::default();
        let event_outcome = dispatch_command(&state, &CardinalCommand::Event(EventCommand::New));
        let invite_outcome =
            dispatch_command(&state, &CardinalCommand::Invite(InviteCommand::Accept));

        assert_eq!(event_outcome.effect, Some(DomainEffectRequest::WriteEvent));
        assert_eq!(invite_outcome.effect, Some(DomainEffectRequest::WriteEvent));
    }
}