sara-tasks 2.5.1

Sara — folder-aware task manager
use std::collections::{HashMap, HashSet};

use anyhow::Result;
use chrono::{Local, Utc};
use rusqlite::Connection;

use crate::commands::shared::{plural, truncate};
use crate::infrastructure::db;
use crate::infrastructure::model::{Priority, Task};

const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";
const GREEN: &str = "\x1b[32m";
const CYAN: &str = "\x1b[36m";
const GRAY: &str = "\x1b[90m";
const MAGENTA: &str = "\x1b[35m";

const DEP_COL_W: usize = 16;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum LinkBadge {
    Pr,
    Issue,
    Link,
    None,
}

impl LinkBadge {
    pub(super) fn from_flags(flags: db::LinkFlags, synced: bool) -> Self {
        if flags.pr {
            LinkBadge::Pr
        } else if flags.issue && synced {
            LinkBadge::Issue
        } else if flags.any {
            LinkBadge::Link
        } else {
            LinkBadge::None
        }
    }
}

pub(super) fn print_table(
    tasks: &[Task],
    link_flags: &HashMap<String, db::LinkFlags>,
    github_synced: &HashSet<String>,
    dep_info: &HashMap<String, db::DepInfo>,
    filter: Option<&str>,
    no_color: bool,
) {
    let header = format!(
        "    {id:>3}  {pri:<4}  {proj:<16}  {due:<12}  {urg:>6}  {dep:<16}  {desc}",
        id = "ID",
        pri = "PRI",
        proj = "PROJECT",
        due = "DUE",
        urg = "URG",
        dep = "DEPS",
        desc = "DESCRIPTION",
    );
    if no_color {
        println!("{header}");
        println!("{}", "─".repeat(header.len()));
    } else {
        println!("{BOLD}{header}{RESET}");
        println!("{DIM}{}{RESET}", "─".repeat(80));
    }

    for task in tasks {
        let id_str = task
            .id
            .map(|i| i.to_string())
            .unwrap_or_else(|| "-".to_string());
        let pri_str = task
            .priority
            .as_ref()
            .map(|p| p.label().to_string())
            .unwrap_or_else(|| "-".to_string());
        let due_str = task
            .due
            .map(|d| d.with_timezone(&Local).format("%Y-%m-%d").to_string())
            .unwrap_or_else(|| "-".to_string());
        let urg_str = format!("{:.1}", task.urgency);

        let proj_display = truncate(&task.project, 16);
        let desc_display = truncate(&task.description, 60);

        let active_marker = if task.is_active() { "●" } else { " " };

        let flags = link_flags
            .get(&task.uuid.to_string())
            .copied()
            .unwrap_or_default();
        let recur_mark = if task.recur.is_some() { "♺" } else { " " };

        let dep = dep_info.get(&task.uuid.to_string());
        let dep_mark = match dep {
            Some(d) if d.is_blocked() => "⊘",
            Some(d) if d.blocking > 0 => "⛓",
            _ => " ",
        };
        let dep_text = truncate(&dep_column_text(dep), DEP_COL_W);
        let is_synced = github_synced.contains(&task.uuid.to_string());
        let link_badge = LinkBadge::from_flags(flags, is_synced);
        let pr_badge_plain = match link_badge {
            LinkBadge::Pr => "[PR] ",
            LinkBadge::Issue => "[ISSUE] ",
            LinkBadge::Link => "[link] ",
            LinkBadge::None => "",
        };

        if no_color {
            println!(
                "{active}{recur}{dep} {id:>3}  {pri:<4}  {proj:<16}  {due:<12}  {urg:>6}  {deptext:<width$}  {pr}{desc}",
                active = active_marker,
                recur = recur_mark,
                dep = dep_mark,
                id = id_str,
                pri = pri_str,
                proj = proj_display,
                due = due_str,
                urg = urg_str,
                deptext = dep_text,
                width = DEP_COL_W,
                pr = pr_badge_plain,
                desc = desc_display,
            );
        } else {
            let pri_colored = match &task.priority {
                Some(Priority::H) => format!("{RED}{BOLD}{pri_str:<4}{RESET}"),
                Some(Priority::M) => format!("{YELLOW}{pri_str:<4}{RESET}"),
                Some(Priority::L) => format!("{GREEN}{pri_str:<4}{RESET}"),
                None => format!("{GRAY}{pri_str:<4}{RESET}"),
            };
            let due_colored = color_due(task, &due_str, no_color);
            let active_col = if task.is_active() {
                format!("{GREEN}●{RESET}")
            } else {
                " ".to_string()
            };
            let recur_col = if task.recur.is_some() {
                format!("{CYAN}♺{RESET}")
            } else {
                " ".to_string()
            };
            let dep_col = match dep {
                Some(d) if d.is_blocked() => format!("{RED}⊘{RESET}"),
                Some(d) if d.blocking > 0 => format!("{CYAN}⛓{RESET}"),
                _ => " ".to_string(),
            };
            let dep_padded = format!("{:<width$}", dep_text, width = DEP_COL_W);
            let dep_text_col = match dep {
                Some(d) if d.is_blocked() => format!("{RED}{dep_padded}{RESET}"),
                Some(d) if d.blocking > 0 => format!("{GRAY}{dep_padded}{RESET}"),
                _ => dep_padded,
            };
            let pr_badge = match link_badge {
                LinkBadge::Pr => format!("{MAGENTA}{BOLD}PR{RESET} "),
                LinkBadge::Issue => format!("{GREEN}{BOLD}ISS{RESET} "),
                LinkBadge::Link => format!("{CYAN}↗{RESET} "),
                LinkBadge::None => String::new(),
            };
            println!(
                "{active}{recur}{dep} {CYAN}{id:>3}{RESET}  {pri}  {GRAY}{proj:<16}{RESET}  {due:<12}  {GRAY}{urg:>6}{RESET}  {deptext}  {pr}{desc}",
                active = active_col,
                recur = recur_col,
                dep = dep_col,
                id = id_str,
                pri = pri_colored,
                proj = proj_display,
                due = due_colored,
                urg = urg_str,
                deptext = dep_text_col,
                pr = pr_badge,
                desc = desc_display,
            );
        }
    }

    println!();
    let summary = format!(
        "Showing {} task{}{}",
        tasks.len(),
        plural(tasks.len()),
        filter
            .map(|p| format!(" for project '{p}'"))
            .unwrap_or_default()
    );
    if no_color {
        println!("{summary}");
    } else {
        println!("{DIM}{summary}{RESET}");
    }
}

pub(super) fn print_by_issue(conn: &Connection, tasks: &[Task], no_color: bool) -> Result<()> {
    let (groups, ungrouped) = db::group_tasks_by_issue(conn, tasks)?;

    if groups.is_empty() && ungrouped.is_empty() {
        println!("No pending tasks.");
        return Ok(());
    }

    let print_group_rows = |tasks: &[Task]| {
        for task in tasks {
            let id_str = task
                .id
                .map(|i| i.to_string())
                .unwrap_or_else(|| "-".to_string());
            println!("   {:>3}  {}", id_str, truncate(&task.description, 60));
        }
    };

    for group in &groups {
        let header = format!("Issue #{} · {}", group.number, group.owner_repo);
        if no_color {
            println!("{header}");
        } else {
            println!("{BOLD}{CYAN}{header}{RESET}");
        }
        print_group_rows(&group.tasks);
        println!();
    }

    if !ungrouped.is_empty() {
        if no_color {
            println!("No linked issue");
        } else {
            println!("{DIM}No linked issue{RESET}");
        }
        print_group_rows(&ungrouped);
        println!();
    }

    Ok(())
}

fn fmt_id_list(ids: &[i64]) -> String {
    ids.iter()
        .map(|i| i.to_string())
        .collect::<Vec<_>>()
        .join(",")
}

fn dep_column_text(dep: Option<&db::DepInfo>) -> String {
    match dep {
        Some(d) if d.is_blocked() => format!("blocked by {}", fmt_id_list(&d.blocked_by)),
        Some(d) if d.blocking > 0 => format!("blocks {} task{}", d.blocking, plural(d.blocking)),
        _ => String::new(),
    }
}

fn color_due(task: &Task, due_str: &str, no_color: bool) -> String {
    if no_color || task.due.is_none() {
        return format!("{due_str:<12}");
    }
    let now = Utc::now();
    let days = task.due.map(|d| (d - now).num_days()).unwrap_or(999);
    let color = if days < 0 {
        RED
    } else if days <= 1 {
        YELLOW
    } else {
        RESET
    };
    format!("{color}{due_str:<12}{RESET}")
}

#[cfg(test)]
#[path = "../../../tests/unit/commands/list/render.rs"]
mod tests;