aven-core 0.1.12

Core library for the Aven local-first task manager
use crate::ids::WorkspaceId;
use std::collections::HashSet;

use anyhow::{Result, bail};
use sqlx::SqliteConnection;

use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
use crate::db::{Database, begin_immediate};
use crate::ids::{TaskId, now};
use crate::refs::get_task_in_workspace;
use crate::workspaces::Workspace;

impl Database {
    pub async fn add_task_dependency(
        &self,
        workspace: &Workspace,
        task_id: &TaskId,
        depends_on_id: &TaskId,
    ) -> Result<DependencyOutcome> {
        let mut conn = self.acquire().await?;
        add_task_dependency(&mut conn, workspace, task_id, depends_on_id).await
    }

    pub async fn remove_task_dependency(
        &self,
        workspace: &Workspace,
        task_id: &TaskId,
        depends_on_id: &TaskId,
    ) -> Result<DependencyOutcome> {
        let mut conn = self.acquire().await?;
        remove_task_dependency(&mut conn, workspace, task_id, depends_on_id).await
    }
}

pub struct DependencyOutcome {
    pub task: crate::types::Task,
    pub depends_on: crate::types::Task,
    pub changed: bool,
}

struct DependencyPair {
    task: crate::types::Task,
    depends_on: crate::types::Task,
}

async fn load_dependency_pair(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    depends_on_id: &crate::ids::TaskId,
) -> Result<DependencyPair> {
    if task_id == depends_on_id {
        bail!("error dependency-self task_id={task_id}");
    }

    let task = get_task_in_workspace(conn, workspace, task_id).await?;
    let depends_on = get_task_in_workspace(conn, workspace, depends_on_id).await?;

    Ok(DependencyPair { task, depends_on })
}

async fn record_dependency_change(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    pair: &DependencyPair,
    op_type: &'static str,
) -> Result<()> {
    append_change(
        conn,
        ChangeEntity::Task,
        &pair.task.id,
        Some("dependencies"),
        op_type,
        ChangePayload::workspace(workspace).set("depends_on_task_id", pair.depends_on.id.clone()),
    )
    .await?;
    Ok(())
}

pub async fn add_task_dependency(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    depends_on_id: &crate::ids::TaskId,
) -> Result<DependencyOutcome> {
    let mut tx = begin_immediate(conn).await?;
    let pair = load_dependency_pair(&mut tx, workspace, task_id, depends_on_id).await?;

    if dependency_path_exists(
        &mut tx,
        &pair.task.workspace_id,
        &pair.depends_on.id,
        &pair.task.id,
    )
    .await?
    {
        bail!("error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_id}");
    }

    let created_at = now();
    let changed = sqlx::query(
        "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
         VALUES (?, ?, ?, ?)",
    )
    .bind(&pair.task.workspace_id)
    .bind(&pair.task.id)
    .bind(&pair.depends_on.id)
    .bind(&created_at)
    .execute(&mut *tx)
    .await?
    .rows_affected()
        > 0;

    if changed {
        record_dependency_change(&mut tx, workspace, &pair, op_type::DEPENDENCY_ADD).await?;
    }

    tx.commit().await?;
    Ok(DependencyOutcome {
        task: pair.task,
        depends_on: pair.depends_on,
        changed,
    })
}

pub async fn remove_task_dependency(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    depends_on_id: &crate::ids::TaskId,
) -> Result<DependencyOutcome> {
    let mut tx = begin_immediate(conn).await?;
    let pair = load_dependency_pair(&mut tx, workspace, task_id, depends_on_id).await?;

    let changed = sqlx::query(
        "DELETE FROM task_dependencies
         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
    )
    .bind(&pair.task.workspace_id)
    .bind(&pair.task.id)
    .bind(&pair.depends_on.id)
    .execute(&mut *tx)
    .await?
    .rows_affected()
        > 0;

    if changed {
        record_dependency_change(&mut tx, workspace, &pair, op_type::DEPENDENCY_REMOVE).await?;
    }

    tx.commit().await?;
    Ok(DependencyOutcome {
        task: pair.task,
        depends_on: pair.depends_on,
        changed,
    })
}

pub async fn dependency_path_exists(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    from_task_id: &crate::ids::TaskId,
    to_task_id: &crate::ids::TaskId,
) -> Result<bool> {
    let mut visited = HashSet::new();
    let mut stack = vec![from_task_id.clone()];
    while let Some(current) = stack.pop() {
        if !visited.insert(current.clone()) {
            continue;
        }
        if &current == to_task_id {
            return Ok(true);
        }
        let next = sqlx::query_scalar::<_, crate::ids::TaskId>(
            "SELECT depends_on_task_id
             FROM task_dependencies
             WHERE workspace_id = ? AND task_id = ?",
        )
        .bind(workspace_id)
        .bind(&current)
        .fetch_all(&mut *conn)
        .await?;
        stack.extend(next);
    }
    Ok(false)
}