hj-cli 0.1.2

CLI for handoff state, reconciliation, and handup surveys
Documentation
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, anyhow};
use hj_core::{HandupItem, HandupProject, HandupRecommendation, HandupReport};
use hj_git::{
    SurveyHandoff, TodoMarker, discover, discover_handoffs, discover_todo_markers, today,
};
use hj_sqlite::{HandupCheckpoint, HandupDb};

use crate::cli::HandupArgs;

pub(crate) fn handup(args: HandupArgs) -> Result<()> {
    let cwd =
        fs::canonicalize(Path::new(".")).context("failed to canonicalize current directory")?;
    let generated = today(&cwd)?;
    let basename = cwd
        .file_name()
        .and_then(|value| value.to_str())
        .ok_or_else(|| anyhow!("current directory has no basename"))?;

    let surveys = discover_handoffs(&cwd, args.max_depth)?;
    let markers = discover_todo_markers(&cwd, args.max_depth)?;
    let report = build_handup_report(&cwd, &generated, surveys, markers);

    let handup_dir = handup_output_dir()?;
    fs::create_dir_all(&handup_dir)
        .with_context(|| format!("failed to create {}", handup_dir.display()))?;
    let json_path = handup_dir.join("HANDUP.json");
    fs::write(&json_path, serde_json::to_string_pretty(&report)?)
        .with_context(|| format!("failed to write {}", json_path.display()))?;

    let db = HandupDb::new()?;
    let db_path = db.checkpoint(&HandupCheckpoint {
        project: basename.to_string(),
        cwd: cwd.display().to_string(),
        generated: generated.clone(),
        recommendation: report.recommendation.reason.clone(),
        json_path: json_path.display().to_string(),
    })?;

    print_handup_summary(&report, &json_path, &db_path);
    Ok(())
}

fn build_handup_report(
    cwd: &Path,
    generated: &str,
    surveys: Vec<SurveyHandoff>,
    markers: Vec<TodoMarker>,
) -> HandupReport {
    let marker_groups = group_markers(markers);
    let mut projects_by_root = BTreeMap::<String, HandupProject>::new();

    for survey in surveys {
        let repo_root = survey.repo_root.display().to_string();
        let todos = marker_groups.get(&repo_root).cloned().unwrap_or_default();
        let entry = projects_by_root
            .entry(repo_root.clone())
            .or_insert_with(|| HandupProject {
                name: survey.project_name.clone(),
                path: repo_root.clone(),
                repo_root: repo_root.clone(),
                handoff_path: Some(survey.path.display().to_string()),
                branch: survey.branch.clone(),
                build: survey.build.clone(),
                tests: survey.tests.clone(),
                items: Vec::new(),
                todos: Vec::new(),
            });

        if entry.handoff_path.is_none() {
            entry.handoff_path = Some(survey.path.display().to_string());
        }
        if entry.branch.is_none() {
            entry.branch = survey.branch.clone();
        }
        if entry.build.is_none() {
            entry.build = survey.build.clone();
        }
        if entry.tests.is_none() {
            entry.tests = survey.tests.clone();
        }
        for item in survey.items {
            let priority = item.inferred_priority();
            let status = item.status.clone().unwrap_or_else(|| "open".into());
            entry.items.push(HandupItem {
                id: item.id,
                priority,
                status,
                title: item.title,
            });
        }
        if entry.items.is_empty() || entry.todos.len() <= 5 {
            entry.todos = todos;
        }
    }

    for (repo_root, todos) in marker_groups {
        projects_by_root
            .entry(repo_root.clone())
            .and_modify(|project| {
                if project.handoff_path.is_none() || todos.len() > 5 {
                    project.todos = todos.clone();
                }
            })
            .or_insert_with(|| {
                let name = Path::new(&repo_root)
                    .file_name()
                    .and_then(|value| value.to_str())
                    .unwrap_or("unknown")
                    .to_string();
                HandupProject {
                    name,
                    path: repo_root.clone(),
                    repo_root,
                    handoff_path: None,
                    branch: None,
                    build: None,
                    tests: None,
                    items: Vec::new(),
                    todos,
                }
            });
    }

    let mut projects = projects_by_root.into_values().collect::<Vec<_>>();
    projects.sort_by(|left, right| {
        let left_p0 = left
            .items
            .iter()
            .filter(|item| item.priority == "P0")
            .count();
        let right_p0 = right
            .items
            .iter()
            .filter(|item| item.priority == "P0")
            .count();
        let left_p1 = left
            .items
            .iter()
            .filter(|item| item.priority == "P1")
            .count();
        let right_p1 = right
            .items
            .iter()
            .filter(|item| item.priority == "P1")
            .count();

        right_p0
            .cmp(&left_p0)
            .then(right_p1.cmp(&left_p1))
            .then(left.name.cmp(&right.name))
    });

    let recommendation = recommend_project(&projects);
    HandupReport {
        generated: generated.to_string(),
        cwd: cwd.display().to_string(),
        projects,
        recommendation,
    }
}

fn group_markers(markers: Vec<TodoMarker>) -> BTreeMap<String, Vec<String>> {
    let mut grouped = BTreeMap::<String, Vec<String>>::new();
    for marker in markers {
        let repo_root = discover(marker.path.parent().unwrap_or(Path::new(".")))
            .map(|context| context.repo_root.display().to_string())
            .unwrap_or_else(|_| {
                marker
                    .path
                    .parent()
                    .unwrap_or(Path::new("."))
                    .display()
                    .to_string()
            });
        grouped.entry(repo_root).or_default().push(format!(
            "{}:{}  {}",
            marker.path.display(),
            marker.line,
            marker.text
        ));
    }
    grouped
}

fn recommend_project(projects: &[HandupProject]) -> HandupRecommendation {
    let Some(project) = projects.first() else {
        return HandupRecommendation {
            project: None,
            reason: "No open handoff items or TODO markers found.".into(),
        };
    };

    let p0 = project
        .items
        .iter()
        .filter(|item| item.priority == "P0")
        .count();
    let p1 = project
        .items
        .iter()
        .filter(|item| item.priority == "P1")
        .count();
    let reason = if p0 > 0 {
        format!("{p0} P0 item(s) need attention")
    } else if p1 > 0 {
        format!("{p1} P1 item(s) ready for follow-up")
    } else if !project.items.is_empty() {
        format!("{} open item(s) remain in handoff", project.items.len())
    } else if !project.todos.is_empty() {
        format!("{} inline TODO marker(s) found", project.todos.len())
    } else {
        "No open work found.".into()
    };

    HandupRecommendation {
        project: Some(project.name.clone()),
        reason,
    }
}

fn handup_output_dir() -> Result<PathBuf> {
    let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?;
    let cwd =
        fs::canonicalize(Path::new(".")).context("failed to canonicalize current directory")?;
    let basename = cwd
        .file_name()
        .and_then(|value| value.to_str())
        .ok_or_else(|| anyhow!("current directory has no basename"))?;
    Ok(home.join(".ctx/handoffs").join(basename))
}

fn print_handup_summary(report: &HandupReport, json_path: &Path, db_path: &Path) {
    if report.projects.is_empty() {
        println!(
            "No open handoff items or TODO markers found under `{}`.",
            report.cwd
        );
        println!("Findings written to: {}", json_path.display());
        println!("Checkpointed to: {}", db_path.display());
        return;
    }

    println!("## handup — {} ({})", report.cwd, report.generated);
    println!();

    for project in &report.projects {
        println!("### {}{}", project.name, project.path);
        if project.branch.is_some() || project.build.is_some() || project.tests.is_some() {
            println!(
                "Branch: {} | Build: {} | Tests: {}",
                project.branch.as_deref().unwrap_or("unknown"),
                project.build.as_deref().unwrap_or("unknown"),
                project.tests.as_deref().unwrap_or("unknown")
            );
        }

        let mut items = project.items.clone();
        items.sort_by(|left, right| {
            left.priority
                .cmp(&right.priority)
                .then(left.id.cmp(&right.id))
        });
        for item in items {
            println!(
                "  {}  [{}] {} (status: {})",
                item.priority, item.id, item.title, item.status
            );
        }
        for todo in &project.todos {
            println!("  TODO  {todo}");
        }
        println!();
    }

    println!("---");
    println!("## Where to next?");
    match report.recommendation.project.as_deref() {
        Some(project) => {
            println!(
                "Highest urgency: {project}{}",
                report.recommendation.reason
            );
        }
        None => {
            println!("{}", report.recommendation.reason);
        }
    }
    if let Some(project) = report.projects.first() {
        println!("Suggested: cd {} && /atelier:handon", project.path);
    }
    println!(
        "Findings written to: {} — checkpointed to {}",
        json_path.display(),
        db_path.display()
    );
}