aven 0.1.4

Local-first task manager CLI and sync server
Documentation
use std::collections::HashSet;

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

use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
use crate::db::begin_immediate;
use crate::ids::now;
use crate::refs::get_task;
use crate::workspaces::workspace_for_id;

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

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

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

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

    if task.workspace_id != depends_on.workspace_id {
        bail!(
            "error dependency-cross-workspace task_id={task_id} depends_on_task_id={depends_on_id}"
        );
    }

    Ok(DependencyPair { task, depends_on })
}

async fn record_dependency_change(
    conn: &mut SqliteConnection,
    pair: &DependencyPair,
    op_type: &'static str,
) -> Result<()> {
    let workspace = workspace_for_id(conn, &pair.task.workspace_id).await?;
    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(crate) async fn add_task_dependency(
    conn: &mut SqliteConnection,
    task_id: &str,
    depends_on_id: &str,
) -> Result<DependencyOutcome> {
    let mut tx = begin_immediate(conn).await?;
    let pair = load_dependency_pair(&mut tx, 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, &pair, op_type::DEPENDENCY_ADD).await?;
    }

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

pub(crate) async fn remove_task_dependency(
    conn: &mut SqliteConnection,
    task_id: &str,
    depends_on_id: &str,
) -> Result<DependencyOutcome> {
    let mut tx = begin_immediate(conn).await?;
    let pair = load_dependency_pair(&mut tx, 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, &pair, op_type::DEPENDENCY_REMOVE).await?;
    }

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

pub(crate) async fn dependency_path_exists(
    conn: &mut SqliteConnection,
    workspace_id: &str,
    from_task_id: &str,
    to_task_id: &str,
) -> Result<bool> {
    let mut visited = HashSet::new();
    let mut stack = vec![from_task_id.to_string()];
    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::<_, String>(
            "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)
}