aven 0.1.16

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

use anyhow::Result;
use aven_core::db::Database;
use serde::Serialize;

use crate::cli::PrimeArgs;
use crate::query::{
    self, SortDirection, TaskAvailabilityFilter, TaskFilters, TaskQueryMode, TaskSort,
};
use crate::render::{print_json_pretty, quote};
use crate::workspaces::Workspace;

pub(crate) async fn run(database: &Database, workspace: &Workspace, args: PrimeArgs) -> Result<()> {
    let report = build_report(database, workspace, args.project.as_deref(), args.limit).await?;
    if args.json {
        print_json_pretty(&report)?;
    } else {
        PrimeTextRenderer::print(&report);
    }
    Ok(())
}

#[derive(Serialize)]
struct PrimeReport {
    project: Option<String>,
    unavailable_reason: Option<String>,
    open_issue_sample: usize,
    conventions: PrimeConventions,
    top_blockers: Vec<PrimeBlocker>,
    active: Vec<PrimeIssue>,
    ready: Vec<PrimeIssue>,
    blocked: Vec<PrimeIssue>,
}

#[derive(Default, Serialize)]
struct PrimeConventions {
    title_style: String,
    statuses: String,
    labels: String,
}

#[derive(Serialize)]
struct PrimeBlocker {
    r#ref: String,
    blocks: i64,
}

#[derive(Serialize)]
struct PrimeIssue {
    r#ref: String,
    status: String,
    priority: String,
    labels: Vec<String>,
    title: String,
    is_epic: bool,
    epic_parent_ref: Option<String>,
    epic_child_refs: Vec<String>,
    blocked_by_refs: Vec<String>,
    blocks_refs: Vec<String>,
}

impl PrimeReport {
    fn unavailable(project: Option<String>, reason: &str) -> Self {
        Self {
            project,
            unavailable_reason: Some(reason.to_string()),
            open_issue_sample: 0,
            conventions: PrimeConventions::default(),
            top_blockers: Vec::new(),
            active: Vec::new(),
            ready: Vec::new(),
            blocked: Vec::new(),
        }
    }
}

async fn build_report(
    database: &Database,
    workspace: &Workspace,
    project_arg: Option<&str>,
    limit: Option<usize>,
) -> Result<PrimeReport> {
    let project = if let Some(project) = project_arg {
        Some(
            database
                .resolve_existing_project(&workspace.id, project)
                .await?
                .key,
        )
    } else {
        crate::projects::inferred_project_key_for_add_with_database(database, workspace).await?
    };

    let Some(project) = project else {
        return Ok(PrimeReport::unavailable(
            None,
            "No current project could be inferred. Run with --project <project>.",
        ));
    };

    if database
        .find_project(&workspace.id, &project)
        .await?
        .is_none()
    {
        return Ok(PrimeReport::unavailable(Some(project), "No open issues."));
    }

    let mut items = database
        .list_task_items(
            &workspace.id,
            prime_task_filters(project.clone()),
            TaskQueryMode::Flat,
            TaskSort::Updated,
            SortDirection::Desc,
        )
        .await?;
    if let Some(limit) = limit {
        items.truncate(limit);
    }

    Ok(report_from_items(project, &items))
}

fn report_from_items(project: String, items: &[query::TaskListItem]) -> PrimeReport {
    let active = items
        .iter()
        .filter(|item| item.task.status.as_str() == "active")
        .map(prime_issue)
        .collect();
    let ready = items
        .iter()
        .filter(|item| item.task.status.as_str() != "active" && item.unresolved_blocker_count == 0)
        .map(prime_issue)
        .collect();
    let blocked = items
        .iter()
        .filter(|item| item.task.status.as_str() != "active" && item.unresolved_blocker_count > 0)
        .map(prime_issue)
        .collect();

    let mut top_blockers = items
        .iter()
        .filter(|item| item.dependent_count > 0)
        .map(|item| PrimeBlocker {
            r#ref: item.display_ref.clone(),
            blocks: item.dependent_count,
        })
        .collect::<Vec<_>>();
    top_blockers.sort_by(|left, right| {
        right
            .blocks
            .cmp(&left.blocks)
            .then_with(|| left.r#ref.cmp(&right.r#ref))
    });
    top_blockers.truncate(3);

    let status_counts = count_values(items.iter().map(|item| item.task.status.as_str()));
    let label_counts = count_values(
        items
            .iter()
            .flat_map(|item| item.labels.iter().map(String::as_str)),
    );

    PrimeReport {
        project: Some(project),
        unavailable_reason: None,
        open_issue_sample: items.len(),
        conventions: PrimeConventions {
            title_style: "capitalized".to_string(),
            statuses: format_counts(&status_counts, 4),
            labels: format_counts(&label_counts, 6),
        },
        top_blockers,
        active,
        ready,
        blocked,
    }
}

fn prime_issue(item: &query::TaskListItem) -> PrimeIssue {
    PrimeIssue {
        r#ref: item.display_ref.clone(),
        status: item.task.status.to_string(),
        priority: item.task.priority.to_string(),
        labels: item.labels.clone(),
        title: item.task.title.clone(),
        is_epic: item.task.is_epic,
        epic_parent_ref: item
            .epic_parent
            .as_ref()
            .map(|parent| parent.display_ref.clone()),
        epic_child_refs: item
            .epic_children
            .iter()
            .map(|child| child.display_ref.clone())
            .collect(),
        blocked_by_refs: unresolved_refs(&item.depends_on),
        blocks_refs: unresolved_refs(&item.blocks),
    }
}

fn unresolved_refs(links: &[query::TaskDependencyLink]) -> Vec<String> {
    links
        .iter()
        .filter(|link| link.unresolved)
        .map(|link| link.display_ref.clone())
        .collect()
}

fn count_values<'a>(values: impl Iterator<Item = &'a str>) -> Vec<(String, usize)> {
    let mut counts = BTreeMap::<String, usize>::new();
    for value in values {
        *counts.entry(value.to_string()).or_default() += 1;
    }
    let mut counts = counts.into_iter().collect::<Vec<_>>();
    counts.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
    counts
}

fn format_counts(counts: &[(String, usize)], limit: usize) -> String {
    counts
        .iter()
        .take(limit)
        .map(|(value, count)| format!("{value}={count}"))
        .collect::<Vec<_>>()
        .join(", ")
}

struct PrimeTextRenderer;

impl PrimeTextRenderer {
    fn print(report: &PrimeReport) {
        Self::print_primer();
        Self::print_issue_workflow();

        if let Some(reason) = &report.unavailable_reason {
            println!("{reason}");
            return;
        }

        Self::print_conventions(report);
        Self::print_open_issues(report);
    }

    fn print_primer() {
        print!("{}", include_str!("../skill.md"));
        if !include_str!("../skill.md").ends_with('\n') {
            println!();
        }
        println!();
    }

    fn print_issue_workflow() {
        println!("## Issue Workflow");
        println!();
        println!("- Inspect an issue with `aven show <ref> --full` before changing it.");
        println!(
            "- Mark picked-up work with `aven edit <ref> --status active` before making changes."
        );
        println!(
            "- Add durable handoff context with `aven note <ref> ...` for blockers, decisions, or partial progress."
        );
        println!("- Leave blocked or unfinished work open and report the current state.");
        println!(
            "- Mark complete with `aven edit <ref> --status done` only after the requested work is complete and required code changes are committed."
        );
        println!("- Use `canceled` only when the user says the issue is no longer needed.");
        println!();
    }

    fn print_conventions(report: &PrimeReport) {
        println!("## Local Conventions");
        println!();
        println!(
            "Project: {}",
            report
                .project
                .as_deref()
                .expect("available report has project")
        );
        println!("Open issue sample: {}", report.open_issue_sample);
        println!("Use {} task titles.", report.conventions.title_style);
        if report.open_issue_sample == 0 {
            println!("No open issues are available for convention summaries.");
        } else {
            println!("Common statuses: {}.", report.conventions.statuses);
            if report.conventions.labels.is_empty() {
                println!("Common labels: none in open issue sample.");
            } else {
                println!("Common labels: {}.", report.conventions.labels);
            }
        }
        println!();
    }

    fn print_open_issues(report: &PrimeReport) {
        println!("## Open Issues");
        println!();
        if report.open_issue_sample == 0 {
            println!("No open issues.");
            return;
        }

        println!(
            "Summary: total={} active={} ready={} blocked={}",
            report.open_issue_sample,
            report.active.len(),
            report.ready.len(),
            report.blocked.len()
        );
        Self::print_top_blockers(&report.top_blockers);
        println!();
        Self::print_issue_section("Active", &report.active);
        Self::print_issue_section("Ready", &report.ready);
        Self::print_issue_section("Blocked", &report.blocked);
    }

    fn print_top_blockers(blockers: &[PrimeBlocker]) {
        if blockers.is_empty() {
            println!("Top blockers: none.");
            return;
        }
        let summary = blockers
            .iter()
            .map(|blocker| format!("{} blocks={}", blocker.r#ref, blocker.blocks))
            .collect::<Vec<_>>();
        println!("Top blockers: {}", summary.join(", "));
    }

    fn print_issue_section(label: &str, issues: &[PrimeIssue]) {
        println!("### {label}");
        if issues.is_empty() {
            println!("(none)");
            println!();
            return;
        }
        for issue in issues {
            println!("{}", format_issue_line(issue));
        }
        println!();
    }
}

fn format_issue_line(issue: &PrimeIssue) -> String {
    let mut fields = vec![format!("{} status={}", issue.r#ref, issue.status)];
    if issue.priority != "none" {
        fields.push(format!("priority={}", issue.priority));
    }
    if !issue.labels.is_empty() {
        fields.push(format!("labels={}", issue.labels.join(",")));
    }
    if let Some(dependencies) = format_dependency_refs(&issue.blocked_by_refs) {
        fields.push(format!("blocked_by={dependencies}"));
    }
    if let Some(dependents) = format_dependency_refs(&issue.blocks_refs) {
        fields.push(format!("blocks={dependents}"));
    }
    fields.push(format!("title={}", quote(&issue.title)));
    fields.join(" ")
}

fn format_dependency_refs(refs: &[String]) -> Option<String> {
    const REF_LIMIT: usize = 3;

    if refs.is_empty() {
        return None;
    }
    let mut parts = refs.iter().take(REF_LIMIT).cloned().collect::<Vec<_>>();
    if refs.len() > REF_LIMIT {
        parts.push(format!("+{}", refs.len() - REF_LIMIT));
    }
    Some(format!("[{}]", parts.join(",")))
}

fn prime_task_filters(project: String) -> TaskFilters {
    TaskFilters {
        hide_done: true,
        exclude_epics: true,
        availability: TaskAvailabilityFilter::Available,
        ..TaskFilters::default().with_project(Some(project))
    }
}