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;