aven-core 0.1.12

Core library for the Aven local-first task manager
use anyhow::{Result, bail};
use sqlx::{Row, SqliteConnection};
use tracing::info;

use crate::change_log::op_type;
use crate::db::{Database, begin_immediate, insert_change, set_field_version};
use crate::ids::TaskId;
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_in_workspace;
use crate::task_fields::TaskField;
use crate::types::Task;
use crate::workspaces::Workspace;

impl Database {
    pub async fn list_conflicts(
        &self,
        workspace: &Workspace,
        project_key: Option<&str>,
        field: Option<&str>,
    ) -> Result<Vec<ConflictListItem>> {
        let mut conn = self.acquire().await?;
        list_conflicts(&mut conn, workspace, project_key, field).await
    }

    pub async fn task_conflicts(
        &self,
        workspace: &Workspace,
        task_id: &TaskId,
        field: Option<&str>,
    ) -> Result<Vec<ConflictDetail>> {
        let mut conn = self.acquire().await?;
        task_conflicts(&mut conn, workspace, task_id, field).await
    }

    pub async fn conflict_variant_value(
        &self,
        workspace: &Workspace,
        task_id: &TaskId,
        field: &str,
        token: &str,
    ) -> Result<String> {
        let mut conn = self.acquire().await?;
        conflict_variant_value(&mut conn, workspace, task_id, field, token).await
    }

    pub async fn resolve_conflict_for_undo(
        &self,
        workspace: &Workspace,
        task_id: &TaskId,
        field: &str,
        value: &str,
    ) -> Result<ConflictResolutionOutcome> {
        let mut conn = self.acquire().await?;
        let before =
            crate::undo::task_field_value(&mut conn, &workspace.id, task_id, field).await?;
        let conflict_id =
            crate::undo::conflict_row_id(&mut conn, &workspace.id, task_id, field).await?;
        let outcome = resolve_conflict(&mut conn, workspace, task_id, field, value).await?;
        let after = crate::undo::task_field_value(&mut conn, &workspace.id, task_id, field).await?;
        Ok(ConflictResolutionOutcome {
            outcome,
            before,
            after,
            conflict_id,
        })
    }

    pub async fn resolve_conflict(
        &self,
        workspace: &Workspace,
        task_id: &TaskId,
        field: &str,
        value: &str,
    ) -> Result<ConflictOutcome> {
        let mut conn = self.acquire().await?;
        resolve_conflict(&mut conn, workspace, task_id, field, value).await
    }
}

pub struct ConflictListItem {
    pub task_id: TaskId,
    pub title: String,
    pub project_key: String,
    pub project_prefix: String,
    pub field: String,
    pub variant_a: String,
    pub variant_b: String,
}

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

pub struct ConflictOutcome {
    pub task: Task,
    pub field: String,
}

pub struct ConflictResolutionOutcome {
    pub outcome: ConflictOutcome,
    pub before: String,
    pub after: String,
    pub conflict_id: i64,
}
pub async fn list_conflicts(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    project_key: Option<&str>,
    field: Option<&str>,
) -> Result<Vec<ConflictListItem>> {
    let workspace_id = &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 async fn task_conflicts(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    field: Option<&str>,
) -> Result<Vec<ConflictDetail>> {
    let workspace_id = &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 async fn conflict_variant_value(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    field: &str,
    token: &str,
) -> Result<String> {
    for detail in task_conflicts(conn, workspace, 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) enum ConflictValueChoice {
    Local,
    Remote,
}

#[derive(Debug)]
pub(crate) struct ConflictNotFoundError {
    task_id: TaskId,
    field: &'static str,
}

impl std::fmt::Display for ConflictNotFoundError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            formatter,
            "error conflict-not-found task_id={} field={}",
            self.task_id, self.field
        )
    }
}

impl std::error::Error for ConflictNotFoundError {}

pub async fn resolve_conflict(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    field: &str,
    value: &str,
) -> Result<ConflictOutcome> {
    resolve_conflict_value(
        conn,
        workspace,
        task_id,
        field,
        ResolutionValue::Explicit(value),
    )
    .await
}

pub(crate) async fn resolve_conflict_choice(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    field: &str,
    choice: ConflictValueChoice,
) -> Result<ConflictOutcome> {
    resolve_conflict_value(
        conn,
        workspace,
        task_id,
        field,
        ResolutionValue::Choice(choice),
    )
    .await
}

enum ResolutionValue<'a> {
    Explicit(&'a str),
    Choice(ConflictValueChoice),
}

async fn resolve_conflict_value(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    task_id: &crate::ids::TaskId,
    field: &str,
    resolution: ResolutionValue<'_>,
) -> Result<ConflictOutcome> {
    let task_field = TaskField::parse_or_unknown(field)?;
    let field = task_field.as_str();
    let mut tx = begin_immediate(conn).await?;
    let value = match resolution {
        ResolutionValue::Explicit(value) => value.to_string(),
        ResolutionValue::Choice(choice) => {
            let values = sqlx::query_as::<_, (String, String)>(
                "SELECT local_value, remote_value FROM conflicts
                 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
            )
            .bind(&workspace.id)
            .bind(task_id)
            .bind(field)
            .fetch_optional(&mut *tx)
            .await?
            .ok_or_else(|| {
                anyhow::Error::new(ConflictNotFoundError {
                    task_id: task_id.clone(),
                    field,
                })
            })?;
            match choice {
                ConflictValueChoice::Local => values.0,
                ConflictValueChoice::Remote => values.1,
            }
        }
    };
    if task_field == TaskField::IsEpic
        && value == "0"
        && crate::operations::task_has_epic_children(&mut tx, &workspace.id, task_id).await?
    {
        bail!("error epic-has-children task_id={task_id}");
    }
    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 {
        return Err(anyhow::Error::new(ConflictNotFoundError {
            task_id: task_id.clone(),
            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_in_workspace(conn, workspace, task_id).await?,
        field: field.to_string(),
    })
}