aven 0.1.2

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

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

use crate::render::quote;
use crate::types::Task;
use crate::workspaces::Workspace;

const DISPLAY_SUFFIX_FLOOR: usize = 4;

pub(crate) async fn get_task(conn: &mut SqliteConnection, id: &str) -> Result<Task> {
    get_task_scoped(conn, None, id).await
}

#[allow(dead_code)]
pub(crate) async fn get_task_in_workspace(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    id: &str,
) -> Result<Task> {
    get_task_scoped(conn, Some(&workspace.id), id).await
}

async fn get_task_scoped(
    conn: &mut SqliteConnection,
    workspace_id: Option<&str>,
    id: &str,
) -> Result<Task> {
    let row = sqlx::query(
        "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
         p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.created_at, t.updated_at,
         t.queue_activity_at, t.deleted, t.is_epic
         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
         WHERE t.id = ? AND (? IS NULL OR t.workspace_id = ?)",
    )
    .bind(id)
    .bind(workspace_id)
    .bind(workspace_id)
    .fetch_one(&mut *conn)
    .await?;
    crate::db::task_from_row(&row)
}

pub(crate) async fn resolve_task_ref(conn: &mut SqliteConnection, input: &str) -> Result<Task> {
    let workspace_id = crate::workspaces::active_workspace_id();
    resolve_task_ref_scoped(conn, Some(&workspace_id), input).await
}

#[allow(dead_code)]
pub(crate) async fn resolve_task_ref_in_workspace(
    conn: &mut SqliteConnection,
    workspace: &Workspace,
    input: &str,
) -> Result<Task> {
    resolve_task_ref_scoped(conn, Some(&workspace.id), input).await
}

async fn resolve_task_ref_scoped(
    conn: &mut SqliteConnection,
    workspace_id: Option<&str>,
    input: &str,
) -> Result<Task> {
    let (hint, suffix) = split_ref(input);
    if suffix.len() < 3 {
        bail!("error ref-too-short input={} minimum=3", input);
    }
    let suffix = suffix.to_ascii_uppercase();
    let rows = sqlx::query(
        "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
         p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.created_at, t.updated_at,
         t.queue_activity_at, t.deleted, t.is_epic
         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
         WHERE t.id LIKE ? || '%' AND (? IS NULL OR t.workspace_id = ?)
         ORDER BY t.id",
    )
    .bind(suffix)
    .bind(workspace_id)
    .bind(workspace_id)
    .fetch_all(&mut *conn)
    .await?;
    let matches = rows
        .into_iter()
        .map(|row| crate::db::task_from_row(&row))
        .collect::<Result<Vec<_>>>()?;
    if matches.is_empty() {
        bail!("error unknown-ref input={}", input);
    }
    if let Some(hint) = hint {
        let hinted: Vec<Task> = matches
            .iter()
            .filter(|task| task.project_prefix.eq_ignore_ascii_case(&hint))
            .cloned()
            .collect();
        if hinted.len() == 1 {
            return Ok(hinted[0].clone());
        }
    }
    if matches.len() == 1 {
        return Ok(matches[0].clone());
    }
    println!("error ambiguous-ref input={}", input);
    for task in matches {
        println!(
            "match {} title={}",
            display_ref(conn, &task).await?,
            quote(&task.title)
        );
    }
    println!("hint \"retry with longer ref\"");
    bail!("ambiguous ref");
}

fn split_ref(input: &str) -> (Option<String>, String) {
    if let Some((prefix, suffix)) = input.split_once('-') {
        (Some(prefix.to_string()), normalize_ref(suffix))
    } else {
        (None, normalize_ref(input))
    }
}

fn normalize_ref(input: &str) -> String {
    input
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric())
        .map(|ch| match ch.to_ascii_uppercase() {
            'O' => '0',
            'I' | 'L' => '1',
            ch => ch,
        })
        .collect()
}

pub(crate) async fn display_ref(conn: &mut SqliteConnection, task: &Task) -> Result<String> {
    Ok(format!(
        "{}-{}",
        task.project_prefix,
        display_suffix_for_workspace(conn, &task.workspace_id, &task.id).await?
    ))
}

pub(crate) async fn display_refs_for_tasks(
    conn: &mut SqliteConnection,
    tasks: &[Task],
) -> Result<HashMap<String, String>> {
    let mut by_workspace = HashMap::<String, Vec<String>>::new();
    for task in tasks {
        by_workspace
            .entry(task.workspace_id.clone())
            .or_default()
            .push(task.id.clone());
    }
    for (workspace_id, ids) in &mut by_workspace {
        *ids = task_ids(conn, workspace_id).await?;
    }
    Ok(tasks
        .iter()
        .map(|task| {
            let display_ref = display_ref_for_id(
                &task.project_prefix,
                &task.id,
                &by_workspace[&task.workspace_id],
            );
            (task.id.clone(), display_ref)
        })
        .collect())
}

pub(crate) async fn display_suffix(conn: &mut SqliteConnection, id: &str) -> Result<String> {
    let workspace_id = crate::workspaces::active_workspace_id();
    display_suffix_for_workspace(conn, &workspace_id, id).await
}

async fn display_suffix_for_workspace(
    conn: &mut SqliteConnection,
    workspace_id: &str,
    id: &str,
) -> Result<String> {
    let ids = task_ids(conn, workspace_id).await?;
    Ok(display_suffix_for_id(id, &ids))
}

async fn task_ids(conn: &mut SqliteConnection, workspace_id: &str) -> Result<Vec<String>> {
    Ok(
        sqlx::query_scalar::<_, String>("SELECT id FROM tasks WHERE workspace_id = ? ORDER BY id")
            .bind(workspace_id)
            .fetch_all(&mut *conn)
            .await?,
    )
}

pub(crate) fn display_ref_for_id(project_prefix: &str, id: &str, ids: &[String]) -> String {
    format!("{}-{}", project_prefix, display_suffix_for_id(id, ids))
}

fn display_suffix_for_id(id: &str, ids: &[String]) -> String {
    let len = display_suffix_len(id, ids);
    id[..len].to_string()
}

fn display_suffix_len(id: &str, ids: &[String]) -> usize {
    for len in DISPLAY_SUFFIX_FLOOR.min(id.len())..=id.len() {
        let prefix = &id[..len];
        let count = ids.iter().filter(|other| other.starts_with(prefix)).count();
        if count <= 1 {
            return len;
        }
    }
    id.len()
}