aven 0.1.1

Local-first task manager CLI and sync server
Documentation
use anyhow::{Result, bail};
use sqlx::{Row, SqliteConnection};
use tracing::info;

use crate::change_log::op_type;
use crate::db::{begin_immediate, insert_change, set_field_version};
use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
use crate::projects::{resolve_existing_project_in_workspace, resolve_project_for_stored_value};
use crate::refs::get_task;
use crate::task_fields::TaskField;
use crate::types::Task;

pub(crate) struct ConflictListItem {
    pub(crate) task_id: String,
    pub(crate) title: String,
    pub(crate) project_key: String,
    pub(crate) project_prefix: String,
    pub(crate) field: String,
    pub(crate) variant_a: String,
    pub(crate) variant_b: String,
}

pub(crate) struct ConflictDetail {
    pub(crate) field: String,
    pub(crate) variant_a: String,
    pub(crate) local_value: String,
    pub(crate) variant_b: String,
    pub(crate) remote_value: String,
}

pub(crate) struct ConflictOutcome {
    pub(crate) task: Task,
    pub(crate) field: String,
}
pub(crate) async fn list_conflicts(
    conn: &mut SqliteConnection,
    project_key: Option<&str>,
    field: Option<&str>,
) -> Result<Vec<ConflictListItem>> {
    let workspace_id = crate::workspaces::active_workspace_id();
    let project_id = if let Some(project) = project_key {
        Some(
            resolve_existing_project_in_workspace(conn, &workspace_id, project)
                .await?
                .id,
        )
    } else {
        None
    };
    let rows = sqlx::query(
        r#"SELECT c.task_id, c.field, c.variant_a, c.variant_b,
                 t.title, p.prefix, p.key AS project_key
                 FROM conflicts c
                 JOIN tasks t ON t.workspace_id = c.workspace_id AND t.id = c.task_id
                 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
                 WHERE c.workspace_id = ? AND c.resolved = 0
                 AND (? IS NULL OR t.project_id = ?)
                 AND (? IS NULL OR c.field = ?)
                 ORDER BY c.created_at"#,
    )
    .bind(&workspace_id)
    .bind(&project_id)
    .bind(&project_id)
    .bind(field)
    .bind(field)
    .fetch_all(&mut *conn)
    .await?;
    Ok(rows
        .into_iter()
        .map(|row| ConflictListItem {
            task_id: row.get("task_id"),
            title: row.get("title"),
            project_key: row.get("project_key"),
            project_prefix: row.get("prefix"),
            field: row.get("field"),
            variant_a: row.get("variant_a"),
            variant_b: row.get("variant_b"),
        })
        .collect())
}

pub(crate) async fn task_conflicts(
    conn: &mut SqliteConnection,
    task_id: &str,
    field: Option<&str>,
) -> Result<Vec<ConflictDetail>> {
    let workspace_id = crate::workspaces::active_workspace_id();
    let rows = sqlx::query(
        r#"SELECT field, variant_a, local_value, variant_b, remote_value
         FROM conflicts
         WHERE workspace_id = ? AND task_id = ? AND resolved = 0 AND (? IS NULL OR field = ?)
         ORDER BY field, id"#,
    )
    .bind(&workspace_id)
    .bind(task_id)
    .bind(field)
    .bind(field)
    .fetch_all(&mut *conn)
    .await?;
    Ok(rows
        .into_iter()
        .map(|row| ConflictDetail {
            field: row.get("field"),
            variant_a: row.get("variant_a"),
            local_value: row.get("local_value"),
            variant_b: row.get("variant_b"),
            remote_value: row.get("remote_value"),
        })
        .collect())
}

pub(crate) async fn conflict_variant_value(
    conn: &mut SqliteConnection,
    task_id: &str,
    field: &str,
    token: &str,
) -> Result<String> {
    for detail in task_conflicts(conn, task_id, Some(field)).await? {
        if token == detail.variant_a {
            return Ok(detail.local_value);
        }
        if token == detail.variant_b {
            return Ok(detail.remote_value);
        }
    }
    bail!("error unknown-variant token={token}")
}

pub(crate) async fn resolve_conflict(
    conn: &mut SqliteConnection,
    task_id: &str,
    field: &str,
    value: &str,
) -> Result<ConflictOutcome> {
    let task_field = TaskField::parse_or_unknown(field)?;
    let field = task_field.as_str();
    let workspace = crate::workspaces::active_workspace();
    if task_field == TaskField::IsEpic
        && value == "0"
        && crate::operations::task_has_epic_children(conn, &workspace.id, task_id).await?
    {
        bail!("error epic-has-children task_id={task_id}");
    }
    let mut tx = begin_immediate(conn).await?;
    let result = sqlx::query(
        "UPDATE conflicts SET resolved = 1 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
    )
    .bind(&workspace.id)
    .bind(task_id)
    .bind(field)
    .execute(&mut *tx)
    .await?;
    if result.rows_affected() != 1 {
        bail!("error conflict-not-found task_id={task_id} field={field}");
    }
    let payload = if task_field.is_project() {
        let project = resolve_project_for_stored_value(&mut tx, &workspace.id, value).await?;
        apply_project_id_in_workspace(&mut tx, &workspace.id, task_id, &project.id).await?;
        TaskField::project_payload(&workspace.id, &workspace.key, &project)
    } else {
        apply_field_value_in_workspace(&mut tx, &workspace.id, task_id, field, value).await?;
        task_field.scalar_payload(&workspace.id, &workspace.key, value)?
    };
    let change_id = insert_change(
        &mut tx,
        "task",
        task_id,
        Some(field),
        op_type::RESOLVE_FIELD,
        payload,
        None,
    )
    .await?;
    set_field_version(&mut tx, task_id, field, &change_id).await?;
    tx.commit().await?;
    info!(task_id = %task_id, field = %field, "conflict resolved");
    Ok(ConflictOutcome {
        task: get_task(conn, task_id).await?,
        field: field.to_string(),
    })
}