aven 0.1.13

Local-first task manager CLI and sync server
Documentation
use anyhow::{Result, bail};
use aven_core::db::Database;

use crate::cli::{TuiArgs, TuiViewArg};
use crate::ids::TaskId;
use crate::tui::store::{TaskFilterModifiers, TaskScope, TaskView, TaskViewState};
use crate::workspaces::Workspace;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiLaunch {
    pub(crate) view_state: TaskViewState,
    pub(crate) startup: TuiStartup,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TuiStartup {
    Browse,
    AddTask { natural: bool },
    AddTaskOnly { natural: bool },
    Detail { task_id: TaskId },
}

impl TuiLaunch {
    pub(crate) async fn resolve(
        database: &Database,
        workspace: &Workspace,
        args: TuiArgs,
    ) -> Result<Self> {
        if let Some(task_ref) = args.task_ref {
            let task = database.resolve_task_ref(workspace, &task_ref).await?;
            return Ok(Self {
                view_state: TaskViewState {
                    view: TaskView::Search,
                    filter_modifiers: TaskFilterModifiers {
                        task_ids: vec![task.id.clone()],
                        ..TaskFilterModifiers::default()
                    },
                    ..TaskViewState::default()
                },
                startup: TuiStartup::Detail { task_id: task.id },
            });
        }

        let scope = match args.project.as_deref() {
            Some("") => {
                crate::projects::inferred_existing_project_key_with_database(database, workspace)
                    .await?
                    .map_or(TaskScope::Workspace, TaskScope::Project)
            }
            Some(project) => TaskScope::Project(
                database
                    .resolve_existing_project(&workspace.id, project)
                    .await?
                    .key,
            ),
            None => TaskScope::Workspace,
        };
        let view = args.view.map_or(TaskView::Queue, TaskView::from);
        if view == TaskView::RecentActions && (args.label.is_some() || args.priority.is_some()) {
            bail!("recent-actions view does not support task filters");
        }
        let label = match args.label {
            Some(label) => Some(
                database
                    .resolve_labels(&workspace.id, std::slice::from_ref(&label))
                    .await?
                    .into_iter()
                    .next()
                    .expect("one label resolves to one label"),
            ),
            None => None,
        };
        let priority = args.priority.map(|priority| priority.as_str().to_string());
        let view_state = TaskViewState {
            scope,
            view,
            filter_modifiers: TaskFilterModifiers {
                label,
                priority,
                ..TaskFilterModifiers::default()
            },
            ..TaskViewState::default()
        };

        let startup = if args.add_task_only {
            TuiStartup::AddTaskOnly {
                natural: args.natural,
            }
        } else if args.add_task {
            TuiStartup::AddTask {
                natural: args.natural,
            }
        } else {
            TuiStartup::Browse
        };
        Ok(Self {
            view_state,
            startup,
        })
    }
}

impl From<TuiViewArg> for TaskView {
    fn from(view: TuiViewArg) -> Self {
        match view {
            TuiViewArg::Queue => Self::Queue,
            TuiViewArg::Columns => Self::Columns,
            TuiViewArg::Open => Self::Open,
            TuiViewArg::Inbox => Self::Inbox,
            TuiViewArg::Active => Self::Active,
            TuiViewArg::Backlog => Self::Backlog,
            TuiViewArg::Todo => Self::Todo,
            TuiViewArg::Done => Self::Done,
            TuiViewArg::Upcoming => Self::Upcoming,
            TuiViewArg::Conflicts => Self::Conflicts,
            TuiViewArg::Epics => Self::Epics,
            TuiViewArg::RecentActions => Self::RecentActions,
        }
    }
}

#[cfg(test)]
mod tests {
    use sqlx::{Sqlite, SqliteConnection, pool::PoolConnection};

    use super::*;

    async fn setup() -> (tempfile::TempDir, Database, PoolConnection<Sqlite>) {
        let temp = tempfile::tempdir().unwrap();
        let database = Database::open(&temp.path().join("test.sqlite"))
            .await
            .unwrap();
        let conn = aven_core::test_support::acquire(&database).await.unwrap();
        (temp, database, conn)
    }

    fn args() -> TuiArgs {
        TuiArgs {
            task_ref: None,
            view: None,
            project: None,
            label: None,
            priority: None,
            add_task: false,
            add_task_only: false,
            natural: false,
        }
    }

    async fn seed_project(conn: &mut SqliteConnection) {
        sqlx::query(
            "INSERT INTO projects(
                workspace_id, id, key, name, prefix, created_at, updated_at
             ) VALUES (?, 'ABCDEFGHJKMNPQRS', 'app', 'App', 'APP', 't', 't')",
        )
        .bind(&Workspace::default().id)
        .execute(&mut *conn)
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn resolves_composed_browse_state() {
        let (_temp, database, mut conn) = setup().await;
        seed_project(&mut conn).await;
        sqlx::query(
            "INSERT INTO labels(workspace_id, name, created_at)
             VALUES (?, 'bug-fix', 't')",
        )
        .bind(&Workspace::default().id)
        .execute(&mut *conn)
        .await
        .unwrap();
        let mut input = args();
        input.project = Some("App".to_string());
        input.view = Some(TuiViewArg::Todo);
        input.label = Some("Bug Fix".to_string());
        input.priority = Some(crate::cli::TuiPriorityArg::High);
        input.add_task = true;
        input.natural = true;
        drop(conn);

        let launch = TuiLaunch::resolve(&database, &Workspace::default(), input)
            .await
            .unwrap();

        assert_eq!(
            launch.view_state.scope,
            TaskScope::Project("app".to_string())
        );
        assert_eq!(launch.view_state.view, TaskView::Todo);
        assert_eq!(
            launch.view_state.filter_modifiers.label.as_deref(),
            Some("bug-fix")
        );
        assert_eq!(
            launch.view_state.filter_modifiers.priority.as_deref(),
            Some("high")
        );
        assert_eq!(launch.startup, TuiStartup::AddTask { natural: true });
    }

    #[tokio::test]
    async fn resolves_deleted_task_as_singleton_search_detail() {
        let (_temp, database, mut conn) = setup().await;
        seed_project(&mut conn).await;
        let task_id: TaskId = "ABCD000000000000".parse().unwrap();
        sqlx::query(
            "INSERT INTO tasks(
                workspace_id, id, title, description, project_id, status, priority,
                created_at, updated_at, queue_activity_at, deleted
             ) VALUES (?, ?, 'deleted task', '', 'ABCDEFGHJKMNPQRS', 'done', 'none',
                       't', 't', 't', 1)",
        )
        .bind(&Workspace::default().id)
        .bind(&task_id)
        .execute(&mut *conn)
        .await
        .unwrap();
        let mut input = args();
        input.task_ref = Some("APP-ABCD".to_string());
        drop(conn);

        let launch = TuiLaunch::resolve(&database, &Workspace::default(), input)
            .await
            .unwrap();

        assert_eq!(launch.view_state.scope, TaskScope::Workspace);
        assert_eq!(launch.view_state.view, TaskView::Search);
        assert_eq!(
            launch.view_state.filter_modifiers.task_ids,
            vec![task_id.clone()]
        );
        assert_eq!(launch.startup, TuiStartup::Detail { task_id });
    }

    #[tokio::test]
    async fn rejects_task_filters_for_recent_actions() {
        let (_temp, database, _conn) = setup().await;
        let mut input = args();
        input.view = Some(TuiViewArg::RecentActions);
        input.priority = Some(crate::cli::TuiPriorityArg::Urgent);

        let error = TuiLaunch::resolve(&database, &Workspace::default(), input)
            .await
            .unwrap_err();

        assert_eq!(
            error.to_string(),
            "recent-actions view does not support task filters"
        );
    }

    #[tokio::test]
    async fn rejects_unknown_label_before_store_construction() {
        let (_temp, database, conn) = setup().await;
        let mut input = args();
        input.label = Some("missing".to_string());
        drop(conn);

        let error = TuiLaunch::resolve(&database, &Workspace::default(), input)
            .await
            .unwrap_err();

        assert_eq!(error.to_string(), "unknown label");
    }
}