aven 0.1.13

Local-first task manager CLI and sync server
Documentation
use std::path::PathBuf;

use anyhow::Result;
use tokio::task::JoinHandle;

use crate::config::AppConfig;
use crate::ids::WorkspaceId;
use crate::operations::TaskDraft;
use crate::tui::natural_add_runtime::spawn_add_task_only_natural;
use crate::tui::store::TuiStore;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum NaturalRetry {
    AddTask,
    Dialog,
}

struct PendingTaskIntake {
    handle: JoinHandle<Result<TaskDraft>>,
    retry: NaturalRetry,
    value: String,
    create_on_success: bool,
}

struct ReadyTaskIntake {
    outcome: Result<TaskDraft>,
    retry: NaturalRetry,
    value: String,
    create_on_success: bool,
}

enum IntakeState {
    Idle,
    Pending(PendingTaskIntake),
    Ready(ReadyTaskIntake),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum IntakeWorkView {
    Idle,
    Pending,
    Ready,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct IntakeViewState<'a> {
    pub(super) add_task_only: bool,
    pub(super) message: Option<&'a str>,
    pub(super) work: IntakeWorkView,
}

pub(super) enum IntakePoll {
    Unchanged,
    Ready { failed: bool },
    Completed(Box<IntakeCompletion>),
}

pub(super) struct IntakeCompletion {
    pub(super) outcome: Result<TaskDraft>,
    pub(super) retry: NaturalRetry,
    pub(super) value: String,
    pub(super) create_on_success: bool,
}

pub(super) struct IntakeController {
    state: IntakeState,
    add_task_only: bool,
    message: Option<String>,
    db_path: Option<PathBuf>,
    config: AppConfig,
}

impl IntakeController {
    pub(super) fn new() -> Self {
        Self {
            state: IntakeState::Idle,
            add_task_only: false,
            message: None,
            db_path: None,
            config: AppConfig::default(),
        }
    }

    pub(super) fn set_config(&mut self, config: AppConfig) {
        self.config = config;
    }

    pub(super) fn set_db_path(&mut self, db_path: PathBuf) {
        self.db_path = Some(db_path);
    }

    pub(super) fn enter_add_task_only(&mut self, config: AppConfig) {
        self.add_task_only = true;
        self.config = config;
    }

    pub(super) fn start(
        &mut self,
        store: &TuiStore,
        raw: String,
        project: Option<String>,
        retry: NaturalRetry,
        value: String,
        create_on_success: bool,
    ) {
        let handle = store.spawn_task_intake(self.config.agent.task_intake.clone(), raw, project);
        self.start_worker(handle, retry, value, create_on_success);
    }

    fn start_worker(
        &mut self,
        handle: JoinHandle<Result<TaskDraft>>,
        retry: NaturalRetry,
        value: String,
        create_on_success: bool,
    ) {
        self.cancel();
        self.state = IntakeState::Pending(PendingTaskIntake {
            handle,
            retry,
            value,
            create_on_success,
        });
    }

    #[cfg(test)]
    pub(super) fn start_handle(
        &mut self,
        handle: JoinHandle<Result<TaskDraft>>,
        retry: NaturalRetry,
        value: String,
        create_on_success: bool,
    ) {
        self.start_worker(handle, retry, value, create_on_success);
    }

    pub(super) fn start_detached(
        &self,
        raw: &str,
        workspace_id: &WorkspaceId,
        project: Option<&str>,
        allow_existing_project: bool,
    ) -> Result<()> {
        spawn_add_task_only_natural(
            raw,
            workspace_id,
            self.db_path.as_deref(),
            project,
            allow_existing_project,
        )
    }

    pub(super) async fn poll(&mut self) -> Result<IntakePoll> {
        if matches!(self.state, IntakeState::Ready(_)) {
            let IntakeState::Ready(ready) = std::mem::replace(&mut self.state, IntakeState::Idle)
            else {
                unreachable!("intake state checked above");
            };
            return Ok(IntakePoll::Completed(Box::new(IntakeCompletion {
                outcome: ready.outcome,
                retry: ready.retry,
                value: ready.value,
                create_on_success: ready.create_on_success,
            })));
        }

        let IntakeState::Pending(pending) = &self.state else {
            return Ok(IntakePoll::Unchanged);
        };
        if !pending.handle.is_finished() {
            return Ok(IntakePoll::Unchanged);
        }

        let IntakeState::Pending(pending) = std::mem::replace(&mut self.state, IntakeState::Idle)
        else {
            unreachable!("intake state checked above");
        };
        let outcome = pending.handle.await?;
        let failed = outcome.is_err();
        self.state = IntakeState::Ready(ReadyTaskIntake {
            outcome,
            retry: pending.retry,
            value: pending.value,
            create_on_success: pending.create_on_success,
        });
        Ok(IntakePoll::Ready { failed })
    }

    pub(super) fn cancel(&mut self) {
        if let IntakeState::Pending(pending) = std::mem::replace(&mut self.state, IntakeState::Idle)
        {
            pending.handle.abort();
        }
    }

    pub(super) fn work_pending(&self) -> bool {
        !matches!(self.state, IntakeState::Idle)
    }

    pub(super) fn config(&self) -> &AppConfig {
        &self.config
    }

    pub(super) fn db_path(&self) -> Option<&std::path::Path> {
        self.db_path.as_deref()
    }

    pub(super) fn view(&self) -> IntakeViewState<'_> {
        IntakeViewState {
            add_task_only: self.add_task_only,
            message: self.message.as_deref(),
            work: match self.state {
                IntakeState::Idle => IntakeWorkView::Idle,
                IntakeState::Pending(_) => IntakeWorkView::Pending,
                IntakeState::Ready(_) => IntakeWorkView::Ready,
            },
        }
    }

    pub(super) fn set_message(&mut self, message: impl Into<String>) {
        self.message = Some(message.into());
    }

    pub(super) fn take_message(&mut self) -> Option<String> {
        self.message.take()
    }
}

impl Drop for IntakeController {
    fn drop(&mut self) {
        self.cancel();
    }
}

#[cfg(test)]
mod tests {
    use std::sync::{Arc, Mutex};

    use super::*;

    struct DropSignal(Arc<Mutex<bool>>);

    impl Drop for DropSignal {
        fn drop(&mut self) {
            *self.0.lock().unwrap() = true;
        }
    }

    fn draft(title: &str) -> TaskDraft {
        TaskDraft {
            title: title.to_string(),
            description: String::new(),
            project: None,
            status: "inbox".to_string(),
            priority: "none".to_string(),
            labels: Vec::new(),
            available_at: None,
            due_on: None,
            is_epic: false,
        }
    }

    #[tokio::test]
    async fn poll_exposes_ready_before_completion() {
        let mut controller = IntakeController::new();
        controller.start_worker(
            tokio::spawn(async { Ok(draft("ready")) }),
            NaturalRetry::Dialog,
            "raw input".to_string(),
            false,
        );

        while matches!(controller.view().work, IntakeWorkView::Pending) {
            if matches!(controller.poll().await.unwrap(), IntakePoll::Ready { .. }) {
                break;
            }
            tokio::task::yield_now().await;
        }

        assert_eq!(controller.view().work, IntakeWorkView::Ready);
        let IntakePoll::Completed(completion) = controller.poll().await.unwrap() else {
            panic!("ready intake should complete on the next poll");
        };
        assert_eq!(completion.outcome.unwrap().title, "ready");
        assert_eq!(completion.retry, NaturalRetry::Dialog);
        assert_eq!(completion.value, "raw input");
        assert!(!completion.create_on_success);
        assert!(!controller.work_pending());
    }

    #[tokio::test]
    async fn starting_new_work_cancels_active_worker() {
        let dropped = Arc::new(Mutex::new(false));
        let signal = DropSignal(Arc::clone(&dropped));
        let mut controller = IntakeController::new();
        controller.start_worker(
            tokio::spawn(async move {
                let _signal = signal;
                std::future::pending::<Result<TaskDraft>>().await
            }),
            NaturalRetry::AddTask,
            "first".to_string(),
            false,
        );
        tokio::task::yield_now().await;

        controller.start_worker(
            tokio::spawn(async { Ok(draft("second")) }),
            NaturalRetry::Dialog,
            "second".to_string(),
            false,
        );
        for _ in 0..100 {
            if *dropped.lock().unwrap() {
                break;
            }
            tokio::task::yield_now().await;
        }

        assert!(*dropped.lock().unwrap());
        assert!(controller.work_pending());
    }

    #[tokio::test]
    async fn cancel_aborts_active_worker_and_clears_work() {
        let dropped = Arc::new(Mutex::new(false));
        let signal = DropSignal(Arc::clone(&dropped));
        let mut controller = IntakeController::new();
        controller.start_worker(
            tokio::spawn(async move {
                let _signal = signal;
                std::future::pending::<Result<TaskDraft>>().await
            }),
            NaturalRetry::AddTask,
            "pending".to_string(),
            false,
        );
        tokio::task::yield_now().await;

        controller.cancel();
        for _ in 0..100 {
            if *dropped.lock().unwrap() {
                break;
            }
            tokio::task::yield_now().await;
        }

        assert!(*dropped.lock().unwrap());
        assert!(!controller.work_pending());
        assert_eq!(controller.view().work, IntakeWorkView::Idle);
    }
}