aven 0.1.13

Local-first task manager CLI and sync server
Documentation
use crate::ids::WorkspaceId;
use std::fs;
use std::path::Path;

use anyhow::{Result, bail};
use aven_core::db::Database;
use serde::Serialize;

use crate::cli::{ConflictCommand, ConflictSubcommand};
use crate::input::read_required_text;
use crate::operations::ConflictDetail;
use crate::refs::DisplayRefContext;
use crate::render::{print_json_pretty, print_multiline_block, print_text_diff, quote};
use crate::types::Task;
use crate::workspaces::Workspace;

#[derive(Serialize)]
struct ConflictListJsonItem {
    r#ref: String,
    task_id: String,
    title: String,
    project: String,
    field: String,
    variants: Vec<String>,
}

#[derive(Serialize)]
struct ConflictDetailJson {
    r#ref: String,
    task_id: String,
    field: String,
    variants: Vec<ConflictVariantJson>,
}

#[derive(Serialize)]
struct ConflictVariantJson {
    token: String,
    value: String,
}

pub(crate) async fn cmd_conflict(
    database: &Database,
    workspace: &Workspace,
    args: ConflictCommand,
) -> Result<()> {
    match args.command {
        ConflictSubcommand::List {
            project,
            field,
            limit,
            json,
        } => {
            let project_key =
                resolve_conflict_project_filter(database, &workspace.id, project).await?;
            let mut items = database
                .list_conflicts(workspace, project_key.as_deref(), field.as_deref())
                .await?;
            if let Some(limit) = limit {
                items.truncate(limit);
            }
            let display_refs = database.display_ref_context(&workspace.id).await?;
            if json {
                let mut json_items = Vec::new();
                for item in items {
                    json_items.push(ConflictListJsonItem {
                        r#ref: display_refs.display_ref_for_id(
                            &workspace.id,
                            &item.project_prefix,
                            &item.task_id,
                        ),
                        task_id: item.task_id.to_string(),
                        title: item.title,
                        project: item.project_key,
                        field: item.field,
                        variants: vec![item.variant_a, item.variant_b],
                    });
                }
                print_json_pretty(&json_items)?;
            } else {
                for item in items {
                    print_conflict_list_item(&display_refs, workspace, item);
                }
            }
        }
        ConflictSubcommand::Show {
            task_ref,
            field,
            json,
        } => {
            let task = database.resolve_task_ref(workspace, &task_ref).await?;
            let details = database
                .task_conflicts(workspace, &task.id, field.as_deref())
                .await?;
            if json {
                let mut json_details = Vec::new();
                for detail in details {
                    let local_value = database
                        .conflict_display_value(
                            &task.workspace_id,
                            &detail.field,
                            &detail.local_value,
                        )
                        .await?;
                    let remote_value = database
                        .conflict_display_value(
                            &task.workspace_id,
                            &detail.field,
                            &detail.remote_value,
                        )
                        .await?;
                    json_details.push(ConflictDetailJson {
                        r#ref: task_ref.clone(),
                        task_id: task.id.to_string(),
                        field: detail.field,
                        variants: vec![
                            ConflictVariantJson {
                                token: detail.variant_a,
                                value: local_value,
                            },
                            ConflictVariantJson {
                                token: detail.variant_b,
                                value: remote_value,
                            },
                        ],
                    });
                }
                print_json_pretty(&json_details)?;
            } else {
                let display_refs = database.display_ref_context(&workspace.id).await?;
                for detail in details {
                    print_conflict_detail(database, &display_refs, &task, detail).await?;
                }
            }
        }
        ConflictSubcommand::Diff { task_ref, field } => {
            let task = database.resolve_task_ref(workspace, &task_ref).await?;
            let detail = load_single_conflict_detail(database, workspace, &task, &field).await?;
            print_text_diff("local", &detail.local_value, "remote", &detail.remote_value);
        }
        ConflictSubcommand::Export {
            task_ref,
            field,
            dir,
        } => {
            let task = database.resolve_task_ref(workspace, &task_ref).await?;
            fs::create_dir_all(&dir)?;
            let detail = load_single_conflict_detail(database, workspace, &task, &field).await?;
            export_conflict_variant(&dir, &detail.field, &detail.variant_a, &detail.local_value)?;
            export_conflict_variant(&dir, &detail.field, &detail.variant_b, &detail.remote_value)?;
        }
        ConflictSubcommand::Resolve {
            task_ref,
            field,
            use_variant,
            value,
            value_file,
            value_stdin,
        } => {
            let task = database.resolve_task_ref(workspace, &task_ref).await?;
            let value = if let Some(token) = use_variant {
                database
                    .conflict_variant_value(workspace, &task.id, &field, &token)
                    .await?
            } else {
                read_required_text(value, value_file.as_deref(), value_stdin, "value")?
            };
            let outcome = database
                .resolve_conflict(workspace, &task.id, &field, &value)
                .await?;
            let display_refs = database.display_ref_context(&workspace.id).await?;
            println!(
                "resolved {} field={}",
                display_refs.display_ref(&outcome.task),
                outcome.field
            );
        }
    }
    Ok(())
}

async fn resolve_conflict_project_filter(
    database: &Database,
    workspace_id: &WorkspaceId,
    project: Option<String>,
) -> Result<Option<String>> {
    if let Some(project) = project {
        return Ok(Some(
            database
                .resolve_existing_project(workspace_id, &project)
                .await?
                .key,
        ));
    }
    Ok(None)
}

fn print_conflict_list_item(
    display_refs: &DisplayRefContext,
    workspace: &Workspace,
    item: crate::operations::ConflictListItem,
) {
    let display =
        display_refs.display_ref_for_id(&workspace.id, &item.project_prefix, &item.task_id);
    println!(
        "{} conflict field={} variants={},{} title={}",
        display,
        item.field,
        item.variant_a,
        item.variant_b,
        quote(&item.title)
    );
}

async fn print_conflict_detail(
    database: &Database,
    display_refs: &DisplayRefContext,
    task: &Task,
    detail: ConflictDetail,
) -> Result<()> {
    println!(
        "conflict {} field={}",
        display_refs.display_ref(task),
        detail.field
    );
    let local_value = database
        .conflict_display_value(&task.workspace_id, &detail.field, &detail.local_value)
        .await?;
    let remote_value = database
        .conflict_display_value(&task.workspace_id, &detail.field, &detail.remote_value)
        .await?;
    println!("variant {}", detail.variant_a);
    print_multiline_block("value", &local_value);
    println!("variant {}", detail.variant_b);
    print_multiline_block("value", &remote_value);
    Ok(())
}

async fn load_single_conflict_detail(
    database: &Database,
    workspace: &Workspace,
    task: &Task,
    field: &str,
) -> Result<ConflictDetail> {
    single_conflict(
        database
            .task_conflicts(workspace, &task.id, Some(field))
            .await?,
        &task.id,
        field,
    )
}

fn export_conflict_variant(dir: &Path, field: &str, variant: &str, value: &str) -> Result<()> {
    let path = dir.join(format!("{field}-{variant}.md"));
    fs::write(&path, value)?;
    println!(
        "exported variant={} path={}",
        variant,
        quote(&path.display().to_string())
    );
    Ok(())
}

pub(super) fn single_conflict(
    details: Vec<ConflictDetail>,
    task_id: &str,
    field: &str,
) -> Result<ConflictDetail> {
    let mut iter = details.into_iter();
    let Some(detail) = iter.next() else {
        bail!("error conflict-not-found task_id={task_id} field={field}");
    };
    if iter.next().is_some() {
        bail!(
            "error multiple-conflicts task_id={task_id} field={field} hint=\"use export to view all variants\""
        );
    }
    Ok(detail)
}