use crate::format::text::{format_priority, format_status_icon, truncate_title};
use crate::model::{Issue, Status};
use crate::output::Theme;
use rich_rust::prelude::*;
pub struct RichIssueTable<'a> {
issues: &'a [Issue],
theme: &'a Theme,
show_type: bool,
max_title_width: Option<usize>,
}
impl<'a> RichIssueTable<'a> {
#[must_use]
pub fn new(issues: &'a [Issue], theme: &'a Theme) -> Self {
Self {
issues,
theme,
show_type: true,
max_title_width: None,
}
}
#[must_use]
pub const fn show_type(mut self, show: bool) -> Self {
self.show_type = show;
self
}
#[must_use]
pub const fn max_title_width(mut self, width: usize) -> Self {
self.max_title_width = Some(width);
self
}
#[must_use]
pub fn build_table(&self) -> Table {
let mut table = Table::new()
.with_column(Column::new("").width(2)) .with_column(Column::new("ID"))
.with_column(Column::new("Pri").justify(JustifyMethod::Center));
if self.show_type {
table = table.with_column(Column::new("Type"));
}
table = table.with_column(Column::new("Title"));
for issue in self.issues {
let status_icon = format_status_icon(&issue.status);
let priority = format_priority(&issue.priority);
let title = self
.max_title_width
.map_or_else(|| issue.title.clone(), |w| truncate_title(&issue.title, w));
let mut cells = Vec::new();
let status_style = self.theme.status_style(&issue.status);
cells.push(Cell::new(status_icon).style(status_style.clone()));
cells.push(Cell::new(&*issue.id).style(self.theme.issue_id.clone()));
let priority_style = self.theme.priority_style(issue.priority);
cells.push(Cell::new(priority).style(priority_style.clone()));
if self.show_type {
let type_str = issue.issue_type.as_str();
let type_style = self.theme.type_style(&issue.issue_type);
cells.push(Cell::new(type_str).style(type_style.clone()));
}
cells.push(Cell::new(title));
table = table.with_row(Row::new(cells));
}
table
}
}
pub struct RichIssuePanel<'a> {
issue: &'a Issue,
theme: &'a Theme,
show_description: bool,
}
impl<'a> RichIssuePanel<'a> {
#[must_use]
pub const fn new(issue: &'a Issue, theme: &'a Theme) -> Self {
Self {
issue,
theme,
show_description: true,
}
}
#[must_use]
pub const fn show_description(mut self, show: bool) -> Self {
self.show_description = show;
self
}
#[must_use]
pub fn build_panel(&self) -> Panel<'static> {
let mut content = String::new();
let status_icon = format_status_icon(&self.issue.status);
content.push_str(&format!("{} {}\n", status_icon, self.issue.title));
let priority = format_priority(&self.issue.priority);
let type_str = self.issue.issue_type.as_str();
let status_str = self.issue.status.as_str();
content.push_str(&format!("[{priority}] [{type_str}] {status_str}\n"));
if self.show_description
&& let Some(desc) = &self.issue.description
{
content.push('\n');
content.push_str(desc);
}
let status_style = self.theme.status_style(&self.issue.status);
let content_lines: Vec<Vec<Segment<'static>>> = content
.lines()
.map(|line| vec![Segment::new(line.to_owned(), None)])
.collect();
Panel::new(content_lines)
.title(self.issue.id.clone())
.border_style(status_style)
}
}
pub struct RichDependencyTree<'a> {
root_issue: &'a Issue,
dependencies: &'a [(String, String)], issues_by_id: &'a std::collections::HashMap<String, &'a Issue>,
#[allow(dead_code)] theme: &'a Theme,
}
impl<'a> RichDependencyTree<'a> {
#[must_use]
pub fn new(
root_issue: &'a Issue,
dependencies: &'a [(String, String)],
issues_by_id: &'a std::collections::HashMap<String, &'a Issue>,
theme: &'a Theme,
) -> Self {
Self {
root_issue,
dependencies,
issues_by_id,
theme,
}
}
#[must_use]
pub fn build_tree(&self) -> Tree {
let root_label = format!(
"{} {} - {}",
format_status_icon(&self.root_issue.status),
self.root_issue.id,
truncate_title(&self.root_issue.title, 40)
);
let mut root = TreeNode::new(root_label);
for (from_id, to_id) in self.dependencies {
if from_id == &self.root_issue.id {
if let Some(dep_issue) = self.issues_by_id.get(to_id) {
let dep_label = format!(
"{} {} - {}",
format_status_icon(&dep_issue.status),
dep_issue.id,
truncate_title(&dep_issue.title, 35)
);
root = root.child(TreeNode::new(dep_label));
} else {
let dep_label = format!("? {} - (unknown)", to_id);
root = root.child(TreeNode::new(dep_label));
}
}
}
Tree::new(root)
}
}
#[must_use]
pub fn format_status_badge(status: &Status, theme: &Theme) -> Text {
let icon = format_status_icon(status);
let label = status.as_str().to_uppercase();
let style = theme.status_style(status);
let mut text = Text::new("");
text.append_styled(&format!("{icon} {label}"), style);
text
}
#[must_use]
pub fn format_count_badges(
open: usize,
in_progress: usize,
blocked: usize,
closed: usize,
theme: &Theme,
) -> Text {
let mut text = Text::new("");
let mut needs_space = false;
if open > 0 {
text.append_styled(&format!("{open} open"), theme.status_open.clone());
needs_space = true;
}
if in_progress > 0 {
if needs_space {
text.append(" ");
}
text.append_styled(
&format!("{in_progress} in progress"),
theme.status_in_progress.clone(),
);
needs_space = true;
}
if blocked > 0 {
if needs_space {
text.append(" ");
}
text.append_styled(&format!("{blocked} blocked"), theme.status_blocked.clone());
needs_space = true;
}
if closed > 0 {
if needs_space {
text.append(" ");
}
text.append_styled(&format!("{closed} closed"), theme.status_closed.clone());
}
text
}
#[must_use]
pub fn build_completion_bar(completed: usize, total: usize, _theme: &Theme) -> ProgressBar {
let progress = if total > 0 {
completed as f64 / total as f64
} else {
0.0
};
let mut bar = ProgressBar::new().width(20).bar_style(BarStyle::Block);
bar.set_progress(progress);
bar
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{IssueType, Priority};
use chrono::Utc;
fn make_test_issue(id: &str, title: &str) -> Issue {
Issue {
id: id.to_string(),
title: title.to_string(),
description: Some("Test description".to_string()),
issue_type: IssueType::Task,
status: Status::Open,
priority: Priority::MEDIUM,
assignee: None,
labels: vec![],
created_at: Utc::now(),
updated_at: Utc::now(),
content_hash: None,
design: None,
acceptance_criteria: None,
notes: None,
owner: None,
estimated_minutes: None,
created_by: None,
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
dependencies: vec![],
comments: vec![],
}
}
#[test]
fn test_rich_issue_table() {
let issues = vec![
make_test_issue("test-1", "First issue"),
make_test_issue("test-2", "Second issue"),
];
let theme = Theme::default();
let table = RichIssueTable::new(&issues, &theme);
let _ = table.build_table();
}
#[test]
fn test_rich_issue_panel() {
let issue = make_test_issue("test-1", "Test issue");
let theme = Theme::default();
let panel = RichIssuePanel::new(&issue, &theme);
let _ = panel.build_panel();
}
#[test]
fn test_format_status_badge() {
let theme = Theme::default();
let _ = format_status_badge(&Status::Open, &theme);
let _ = format_status_badge(&Status::Blocked, &theme);
}
#[test]
fn test_format_count_badges() {
let theme = Theme::default();
let _ = format_count_badges(5, 2, 1, 3, &theme);
}
#[test]
fn test_build_completion_bar() {
let theme = Theme::default();
let _ = build_completion_bar(7, 10, &theme);
}
#[test]
fn test_table_without_type_column() {
let issues = vec![make_test_issue("test-1", "Issue")];
let theme = Theme::default();
let table = RichIssueTable::new(&issues, &theme).show_type(false);
let _ = table.build_table();
}
#[test]
fn test_table_with_title_truncation() {
let issues = vec![make_test_issue(
"test-1",
"A very long title that should be truncated",
)];
let theme = Theme::default();
let table = RichIssueTable::new(&issues, &theme).max_title_width(20);
let _ = table.build_table();
}
}