aven 0.1.16

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

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

use super::validation::{validate_optional_priority, validate_optional_status};
use crate::cli::BulkUpdateArgs;
use crate::ids::WorkspaceId;
use crate::operations::TaskUpdate;
use crate::query::{
    self, SortDirection, TaskAvailabilityFilter, TaskFilters, TaskQueryMode, TaskSort,
};
use crate::refs::DisplayRefContext;
use crate::render::{KvLine, changed_text};
use crate::types::Task;
use crate::workspaces::Workspace;

pub(crate) async fn cmd_bulk_update(
    database: &Database,
    workspace: &Workspace,
    args: BulkUpdateArgs,
) -> Result<()> {
    ensure_bulk_update_has_selector(&args)?;
    ensure_bulk_update_has_mutation(&args)?;
    validate_bulk_update_args(&args)?;

    let workspace_id = workspace.id.clone();
    let labels = resolve_bulk_label_mutations(database, &workspace_id, &args).await?;
    ensure_disjoint_labels(&labels.add, &labels.remove)?;
    let set_project_key = resolve_bulk_project_mutation(database, &workspace_id, &args).await?;

    let filters = bulk_update_filters(&args);
    let display_refs = database.display_ref_context(&workspace.id).await?;
    let items = database
        .list_task_items_with_display_refs(
            &workspace.id,
            filters,
            TaskQueryMode::Flat,
            TaskSort::Updated,
            SortDirection::Desc,
            &display_refs,
        )
        .await?;
    let matched = items.len();
    let planned = plan_bulk_updates(
        database,
        &workspace_id,
        items,
        &args,
        &labels,
        set_project_key.as_deref(),
    )
    .await?;

    let would_change = planned.iter().filter(|item| item.will_change).count();
    let mut changed = 0;
    let mut unchanged = 0;
    for planned in planned {
        let item = planned.item;
        let update = planned.update;
        if args.dry_run {
            print_dry_run_bulk_update(&item, planned.will_change);
            continue;
        }
        if !planned.will_change {
            unchanged += 1;
            print_unchanged_bulk_update(&item);
            continue;
        }
        let outcome = database
            .update_task(workspace, &item.task.id, update)
            .await?;
        changed += 1;
        print_changed_bulk_update(&display_refs, &outcome.task);
    }
    if args.dry_run {
        unchanged = matched - would_change;
    }
    println!(
        "bulk-update-summary matched={matched} changed={changed} would_change={would_change} unchanged={unchanged} dry_run={}",
        if args.dry_run { "yes" } else { "no" }
    );
    Ok(())
}

fn ensure_bulk_update_has_selector(args: &BulkUpdateArgs) -> Result<()> {
    if args.project.is_some()
        || args.status.is_some()
        || args.priority.is_some()
        || args.filter_label.is_some()
        || args.all
    {
        return Ok(());
    }
    bail!("error bulk-update-requires-selector hint=\"add a filter or --all\"");
}

fn ensure_bulk_update_has_mutation(args: &BulkUpdateArgs) -> Result<()> {
    if args.set_status.is_some()
        || args.set_priority.is_some()
        || args.set_project.is_some()
        || !args.label.is_empty()
        || !args.remove_label.is_empty()
    {
        return Ok(());
    }
    bail!("error bulk-update-requires-mutation hint=\"add a mutation flag\"");
}

fn validate_bulk_update_args(args: &BulkUpdateArgs) -> Result<()> {
    validate_optional_status(args.status.as_deref())?;
    validate_optional_priority(args.priority.as_deref())?;
    validate_optional_status(args.set_status.as_deref())?;
    validate_optional_priority(args.set_priority.as_deref())?;
    Ok(())
}

struct BulkLabelMutations {
    add: Vec<String>,
    remove: Vec<String>,
}

struct PlannedBulkUpdate {
    item: query::TaskListItem,
    update: TaskUpdate,
    will_change: bool,
}

async fn resolve_bulk_label_mutations(
    database: &Database,
    workspace_id: &WorkspaceId,
    args: &BulkUpdateArgs,
) -> Result<BulkLabelMutations> {
    let add = dedup_labels(database.resolve_labels(workspace_id, &args.label).await?);
    let remove = dedup_labels(
        database
            .resolve_labels(workspace_id, &args.remove_label)
            .await?,
    );
    Ok(BulkLabelMutations { add, remove })
}

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

fn bulk_update_filters(args: &BulkUpdateArgs) -> TaskFilters {
    TaskFilters {
        label: args.filter_label.clone(),
        availability: TaskAvailabilityFilter::Available,
        ..TaskFilters::default()
            .with_project(args.project.clone())
            .with_status(args.status.clone())
            .with_priority(args.priority.clone())
            .include_deleted(args.include_deleted)
    }
}

async fn plan_bulk_updates(
    database: &Database,
    workspace_id: &WorkspaceId,
    items: Vec<query::TaskListItem>,
    args: &BulkUpdateArgs,
    labels: &BulkLabelMutations,
    set_project_key: Option<&str>,
) -> Result<Vec<PlannedBulkUpdate>> {
    let mut planned = Vec::with_capacity(items.len());
    for item in items {
        let update =
            bulk_update_for_item(&item, args, &labels.add, &labels.remove, set_project_key);
        let will_change = bulk_update_has_changes(&update);
        preflight_bulk_update_item(database, workspace_id, &item, &update).await?;
        planned.push(PlannedBulkUpdate {
            item,
            update,
            will_change,
        });
    }
    Ok(planned)
}

fn print_dry_run_bulk_update(item: &query::TaskListItem, will_change: bool) {
    let line = KvLine::new(format!("would-update {}", item.display_ref))
        .field("changed", changed_text(will_change))
        .field("status", item.task.status)
        .field("priority", item.task.priority)
        .field("labels", item.labels.join(","))
        .quoted("title", &item.task.title)
        .finish();
    println!("{line}");
}

fn print_unchanged_bulk_update(item: &query::TaskListItem) {
    let line = KvLine::new(format!("bulk-updated {}", item.display_ref))
        .field("changed", changed_text(false))
        .field("status", item.task.status)
        .field("priority", item.task.priority)
        .quoted("title", &item.task.title)
        .finish();
    println!("{line}");
}

fn print_changed_bulk_update(display_refs: &DisplayRefContext, task: &Task) {
    let line = KvLine::new(format!("bulk-updated {}", display_refs.display_ref(task)))
        .field("changed", changed_text(true))
        .field("status", task.status)
        .field("priority", task.priority)
        .quoted("title", &task.title)
        .finish();
    println!("{line}");
}

fn ensure_disjoint_labels(add_labels: &[String], remove_labels: &[String]) -> Result<()> {
    let add_labels = add_labels.iter().collect::<HashSet<_>>();
    for label in remove_labels {
        if add_labels.contains(label) {
            bail!("error bulk-update-label-conflict label={label}");
        }
    }
    Ok(())
}

fn dedup_labels(labels: Vec<String>) -> Vec<String> {
    let mut seen = HashSet::new();
    labels
        .into_iter()
        .filter(|label| seen.insert(label.clone()))
        .collect()
}

fn bulk_update_for_item(
    item: &query::TaskListItem,
    args: &BulkUpdateArgs,
    add_labels: &[String],
    remove_labels: &[String],
    set_project_key: Option<&str>,
) -> TaskUpdate {
    TaskUpdate {
        title: None,
        description: None,
        project: set_project_key
            .filter(|project_key| *project_key != item.task.project_key)
            .map(str::to_string),
        status: args
            .set_status
            .as_deref()
            .filter(|status| *status != item.task.status.as_str())
            .map(str::to_string),
        priority: args
            .set_priority
            .as_deref()
            .filter(|priority| *priority != item.task.priority.as_str())
            .map(str::to_string),
        available_at: None,
        due_on: None,
        is_epic: None,
        add_labels: add_labels
            .iter()
            .filter(|label| !item.labels.contains(label))
            .cloned()
            .collect(),
        remove_labels: remove_labels
            .iter()
            .filter(|label| item.labels.contains(label))
            .cloned()
            .collect(),
    }
}

fn bulk_update_has_changes(update: &TaskUpdate) -> bool {
    update.title.is_some()
        || update.description.is_some()
        || update.project.is_some()
        || update.status.is_some()
        || update.priority.is_some()
        || !update.add_labels.is_empty()
        || !update.remove_labels.is_empty()
}

async fn preflight_bulk_update_item(
    database: &Database,
    workspace_id: &WorkspaceId,
    item: &query::TaskListItem,
    update: &TaskUpdate,
) -> Result<()> {
    if update.status.is_some() {
        ensure_bulk_field_clear(
            database,
            workspace_id,
            &item.display_ref,
            &item.task.id,
            "status",
        )
        .await?;
    }
    if update.priority.is_some() {
        ensure_bulk_field_clear(
            database,
            workspace_id,
            &item.display_ref,
            &item.task.id,
            "priority",
        )
        .await?;
    }
    if update.project.is_some() {
        ensure_bulk_field_clear(
            database,
            workspace_id,
            &item.display_ref,
            &item.task.id,
            "project",
        )
        .await?;
    }
    if !update.add_labels.is_empty() || !update.remove_labels.is_empty() {
        ensure_bulk_field_clear(
            database,
            workspace_id,
            &item.display_ref,
            &item.task.id,
            "labels",
        )
        .await?;
    }
    Ok(())
}

async fn ensure_bulk_field_clear(
    database: &Database,
    workspace_id: &WorkspaceId,
    display_ref: &str,
    task_id: &crate::ids::TaskId,
    field: &str,
) -> Result<()> {
    if database
        .conflict_exists(workspace_id, task_id, field)
        .await?
    {
        bail!("error bulk-update-conflicted-field ref={display_ref} field={field}");
    }
    Ok(())
}