aven 0.1.11

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

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

use crate::cli::{ConflictCommand, ConflictSubcommand};
use crate::input::read_required_text;
use crate::operations::{
    ConflictDetail, conflict_variant_value, list_conflicts, resolve_conflict, task_conflicts,
};
use crate::projects::resolve_existing_project_in_workspace;
use crate::refs::{display_ref, display_suffix, resolve_task_ref};
use crate::render::{print_json_pretty, print_multiline_block, print_text_diff, quote};
use crate::task_render::conflict_display_value;
use crate::types::Task;

#[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(conn: &mut SqliteConnection, args: ConflictCommand) -> Result<()> {
    match args.command {
        ConflictSubcommand::List {
            project,
            field,
            limit,
            json,
        } => {
            let project_key = resolve_conflict_project_filter(
                conn,
                crate::workspaces::active_workspace_id().as_str(),
                project,
            )
            .await?;
            let mut items = list_conflicts(conn, project_key.as_deref(), field.as_deref()).await?;
            if let Some(limit) = limit {
                items.truncate(limit);
            }
            if json {
                let mut json_items = Vec::new();
                for item in items {
                    let suffix = display_suffix(conn, &item.task_id).await?;
                    json_items.push(ConflictListJsonItem {
                        r#ref: format!("{}-{}", item.project_prefix, suffix),
                        task_id: item.task_id,
                        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(conn, item).await?;
                }
            }
        }
        ConflictSubcommand::Show {
            task_ref,
            field,
            json,
        } => {
            let task = resolve_task_ref(conn, &task_ref).await?;
            let details = task_conflicts(conn, &task.id, field.as_deref()).await?;
            if json {
                let mut json_details = Vec::new();
                for detail in details {
                    let local_value = conflict_display_value(
                        conn,
                        &task.workspace_id,
                        &detail.field,
                        &detail.local_value,
                    )
                    .await?;
                    let remote_value = conflict_display_value(
                        conn,
                        &task.workspace_id,
                        &detail.field,
                        &detail.remote_value,
                    )
                    .await?;
                    json_details.push(ConflictDetailJson {
                        r#ref: task_ref.clone(),
                        task_id: task.id.clone(),
                        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 {
                for detail in details {
                    print_conflict_detail(conn, &task, detail).await?;
                }
            }
        }
        ConflictSubcommand::Diff { task_ref, field } => {
            let task = resolve_task_ref(conn, &task_ref).await?;
            let detail = load_single_conflict_detail(conn, &task, &field).await?;
            print_text_diff("local", &detail.local_value, "remote", &detail.remote_value);
        }
        ConflictSubcommand::Export {
            task_ref,
            field,
            dir,
        } => {
            let task = resolve_task_ref(conn, &task_ref).await?;
            fs::create_dir_all(&dir)?;
            let detail = load_single_conflict_detail(conn, &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 = resolve_task_ref(conn, &task_ref).await?;
            let value = if let Some(token) = use_variant {
                conflict_variant_value(conn, &task.id, &field, &token).await?
            } else {
                read_required_text(value, value_file.as_deref(), value_stdin, "value")?
            };
            let outcome = resolve_conflict(conn, &task.id, &field, &value).await?;
            println!(
                "resolved {} field={}",
                display_ref(conn, &outcome.task).await?,
                outcome.field
            );
        }
    }
    Ok(())
}

async fn resolve_conflict_project_filter(
    conn: &mut SqliteConnection,
    workspace_id: &str,
    project: Option<String>,
) -> Result<Option<String>> {
    if let Some(project) = project {
        return Ok(Some(
            resolve_existing_project_in_workspace(conn, workspace_id, &project)
                .await?
                .key,
        ));
    }
    Ok(None)
}

async fn print_conflict_list_item(
    conn: &mut SqliteConnection,
    item: crate::operations::ConflictListItem,
) -> Result<()> {
    let display = format!(
        "{}-{}",
        item.project_prefix,
        display_suffix(conn, &item.task_id).await?
    );
    println!(
        "{} conflict field={} variants={},{} title={}",
        display,
        item.field,
        item.variant_a,
        item.variant_b,
        quote(&item.title)
    );
    Ok(())
}

async fn print_conflict_detail(
    conn: &mut SqliteConnection,
    task: &Task,
    detail: ConflictDetail,
) -> Result<()> {
    println!(
        "conflict {} field={}",
        display_ref(conn, task).await?,
        detail.field
    );
    let local_value =
        conflict_display_value(conn, &task.workspace_id, &detail.field, &detail.local_value)
            .await?;
    let remote_value = conflict_display_value(
        conn,
        &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(
    conn: &mut SqliteConnection,
    task: &Task,
    field: &str,
) -> Result<ConflictDetail> {
    single_conflict(
        task_conflicts(conn, &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)
}