#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListColumn {
Num,
Id,
Title,
Status,
Priority,
Cycle,
Milestone,
Reviewer,
Start,
Due,
Overdue,
Blocked,
Relations,
Progress,
Projects,
Epics,
Category,
Wip,
Archived,
Health,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RelationshipCells {
pub blocked: String,
pub relationships: String,
}
impl ListColumn {
pub(super) fn parse(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"num" => Ok(Self::Num),
"id" | "hash" => Ok(Self::Id),
"title" => Ok(Self::Title),
"status" => Ok(Self::Status),
"priority" => Ok(Self::Priority),
"cycle" => Ok(Self::Cycle),
"milestone" => Ok(Self::Milestone),
"reviewer" => Ok(Self::Reviewer),
"start" => Ok(Self::Start),
"due" => Ok(Self::Due),
"overdue" => Ok(Self::Overdue),
"blocked" => Ok(Self::Blocked),
"relations" | "relationships" => Ok(Self::Relations),
"progress" | "children" => Ok(Self::Progress),
"projects" | "project" => Ok(Self::Projects),
"epics" | "epic" => Ok(Self::Epics),
"category" => Ok(Self::Category),
"archived" => Ok(Self::Archived),
"health" => Ok(Self::Health),
"wip" => Ok(Self::Wip),
_ => Err(TixError::InvalidInput(format!(
"unknown list column `{value}`; expected one of num,id,title,status,priority,cycle,milestone,reviewer,start,due,overdue,blocked,relations,progress,projects,epics,category,archived,wip,health"
))),
}
}
pub(super) fn header(self) -> &'static str {
match self {
Self::Num => "NUM",
Self::Id => "ID",
Self::Title => "TITLE",
Self::Status => "STATUS",
Self::Priority => "PRIORITY",
Self::Cycle => "CYCLE",
Self::Milestone => "MILESTONE",
Self::Reviewer => "REVIEWER",
Self::Start => "START",
Self::Due => "DUE",
Self::Overdue => "OVERDUE",
Self::Blocked => "BLOCKED",
Self::Relations => "RELATIONS",
Self::Progress => "CHILDREN",
Self::Projects => "PROJECTS",
Self::Epics => "EPICS",
Self::Category => "CATEGORY",
Self::Archived => "ARCHIVED",
Self::Wip => "WIP",
Self::Health => "HEALTH",
}
}
pub(super) fn cap(self) -> usize {
match self {
Self::Num => 6,
Self::Id => 8,
Self::Title => 40,
Self::Status => 16,
Self::Priority => 12,
Self::Cycle | Self::Milestone | Self::Reviewer => 20,
Self::Start | Self::Due => 10,
Self::Overdue => 7,
Self::Blocked | Self::Relations => 40,
Self::Progress => 16,
Self::Projects => 60,
Self::Epics => 60,
Self::Category => 14,
Self::Archived => 8,
Self::Wip => 20,
Self::Health => 22,
}
}
}
pub fn parse_columns(value: &str) -> Result<Vec<ListColumn>> {
if value.trim().is_empty() {
return Err(TixError::InvalidInput(
"list columns must not be empty".to_string(),
));
}
let mut columns = Vec::new();
for value in value.split(',') {
let column = ListColumn::parse(value.trim())?;
if columns.contains(&column) {
return Err(TixError::InvalidInput(format!(
"duplicate list column `{}`",
value.trim()
)));
}
columns.push(column);
}
Ok(columns)
}
pub(super) fn default_columns(issues: &[Issue]) -> Vec<ListColumn> {
let mut columns = vec![ListColumn::Num, ListColumn::Id, ListColumn::Title];
if issues.iter().any(|issue| issue.archived) {
columns.push(ListColumn::Archived);
}
columns
}
pub(super) const RELATIONSHIP_COLUMNS: [ListColumn; 2] =
[ListColumn::Blocked, ListColumn::Relations];
pub(super) fn append_show_columns(columns: &mut Vec<ListColumn>, shows: &[&str]) {
let mut push = |column: ListColumn| {
if !columns.contains(&column) {
columns.push(column);
}
};
if shows.contains(&"relationships") {
for column in RELATIONSHIP_COLUMNS {
push(column);
}
}
if shows.contains(&"progress") {
push(ListColumn::Progress);
push(ListColumn::Projects);
push(ListColumn::Epics);
}
if shows.contains(&"category") {
push(ListColumn::Category);
}
if shows.contains(&"wip") {
push(ListColumn::Wip);
}
if shows.contains(&"health") {
push(ListColumn::Health);
}
}
pub(super) fn wants_show(show: Option<&str>) -> Result<Vec<&'static str>> {
let mut out: Vec<&'static str> = Vec::new();
for token in show.unwrap_or("").split(',').map(str::trim) {
if token.is_empty() {
continue;
}
let known = match token {
"relationships" => "relationships",
"progress" => "progress",
"category" => "category",
"wip" => "wip",
"health" => "health",
other => {
return Err(TixError::InvalidInput(format!(
"unknown --show value `{other}`; this surface understands \
`relationships`, `progress`, `category`, `wip` and `health`"
)));
}
};
if !out.contains(&known) {
out.push(known);
}
}
Ok(out)
}
pub fn render_table(
issues: &[Issue],
columns: &[ListColumn],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
width: usize,
) -> String {
render_table_with(
issues, columns, group, resolution, width, None, None, None, None, None, None, None, None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_table_with(
issues: &[Issue],
columns: &[ListColumn],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
width: usize,
derived: Option<&BTreeMap<u32, RelationshipCells>>,
hierarchy: Option<&BTreeMap<u32, String>>,
wip: Option<&crate::wip::WipSnapshot>,
health: Option<&crate::health::PortableHealth>,
projects: Option<&BTreeMap<u32, String>>,
epics: Option<&BTreeMap<u32, String>>,
project_groups: Option<&BTreeMap<u32, String>>,
epic_groups: Option<&BTreeMap<u32, String>>,
) -> String {
let (header, rows) = table_rows(
issues, columns, resolution, derived, hierarchy, wip, health, projects, epics,
);
render_table_from(
issues,
columns,
group,
resolution,
width,
&header,
&rows,
project_groups,
epic_groups,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn table_rows(
issues: &[Issue],
columns: &[ListColumn],
resolution: &crate::workflow::CategoryResolution,
derived: Option<&BTreeMap<u32, RelationshipCells>>,
hierarchy: Option<&BTreeMap<u32, String>>,
wip: Option<&crate::wip::WipSnapshot>,
health: Option<&crate::health::PortableHealth>,
projects: Option<&BTreeMap<u32, String>>,
epics: Option<&BTreeMap<u32, String>>,
) -> (Vec<String>, Vec<Vec<String>>) {
let today = crate::model::today();
let rows: Vec<Vec<String>> = issues
.iter()
.map(|issue| {
let cells = derived.and_then(|map| map.get(&issue.number));
columns
.iter()
.map(|column| {
cell(
*column, issue, resolution, &today, cells, hierarchy, wip, health,
projects, epics,
)
})
.collect()
})
.collect();
let header = columns
.iter()
.map(|column| column.header().to_string())
.collect();
(header, rows)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_table_from(
issues: &[Issue],
columns: &[ListColumn],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
width: usize,
header: &[String],
rows: &[Vec<String>],
project_groups: Option<&BTreeMap<u32, String>>,
epic_groups: Option<&BTreeMap<u32, String>>,
truncate: bool,
) -> String {
let natural: Vec<usize> = columns
.iter()
.enumerate()
.map(|(index, column)| {
let cap = if truncate && width == 0 && *column == ListColumn::Title {
usize::MAX
} else {
column.cap()
};
rows.iter()
.map(|row| display_width(&row[index]))
.chain(std::iter::once(display_width(column.header())))
.max()
.unwrap_or(1)
.min(cap)
.max(1)
})
.collect();
let separator_width = columns.len().saturating_sub(1) * 2;
let natural_width = separator_width + natural.iter().sum::<usize>();
let width = if width == 0 { natural_width } else { width };
let available = width.saturating_sub(separator_width).max(columns.len());
let title = columns
.iter()
.position(|column| *column == ListColumn::Title);
let mut widths = vec![1; columns.len()];
let mut remaining = available;
for (index, column) in columns.iter().enumerate() {
if *column == ListColumn::Title {
continue;
}
let take = natural[index].min(remaining.saturating_sub(usize::from(title.is_some())));
widths[index] = take.max(1);
remaining = remaining.saturating_sub(widths[index]);
}
if let Some(title) = title {
widths[title] = remaining;
}
let line_width = width;
let mut out = String::new();
render_row(&mut out, header, &widths, line_width, truncate);
if let Some(group) = group.filter(|group| {
*group == "cycle"
|| *group == "milestone"
|| (*group == "project" && project_groups.is_some())
|| (*group == "epic" && epic_groups.is_some())
}) {
let mut progress: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for issue in issues {
let key = group_key(issue, group, project_groups, epic_groups);
let entry = progress.entry(key).or_insert((0, 0));
entry.1 += 1;
if crate::workflow::is_completed(issue, resolution) {
entry.0 += 1;
}
}
let mut current = String::new();
for (issue, row) in issues.iter().zip(rows) {
let key = group_key(issue, group, project_groups, epic_groups);
if current != key {
current = key.clone();
let (done, total) = progress.get(&key).copied().unwrap_or((0, 0));
let pct = (done * 100).checked_div(total).unwrap_or(0);
out.push_str(&format!(
"-- {group}: {key} ({done}/{total} done, {pct}%) --\n"
));
}
render_row(&mut out, row, &widths, line_width, truncate);
}
} else {
for row in rows {
render_row(&mut out, row, &widths, line_width, truncate);
}
}
out
}
pub(super) fn render_row(
out: &mut String,
cells: &[String],
widths: &[usize],
line_width: usize,
truncate: bool,
) {
if truncate {
let values: Vec<String> = cells
.iter()
.zip(widths)
.map(|(cell, width)| truncate_cell(cell, *width))
.collect();
for (index, value) in values.iter().enumerate() {
if index > 0 {
out.push_str(" ");
}
out.push_str(value);
out.push_str(&" ".repeat(widths[index].saturating_sub(display_width(value))));
}
let padding = line_width
.saturating_sub(separator_width(values.len()))
.saturating_sub(widths.iter().sum::<usize>());
out.push_str(&" ".repeat(padding));
out.push('\n');
return;
}
let wrapped: Vec<Vec<String>> = cells
.iter()
.zip(widths)
.map(|(cell, width)| wrap_cell(cell, *width))
.collect();
let height = wrapped.iter().map(Vec::len).max().unwrap_or(1);
for row in 0..height {
for (index, cell) in wrapped.iter().enumerate() {
if index > 0 {
out.push_str(" ");
}
let value = cell.get(row).map(String::as_str).unwrap_or("");
out.push_str(value);
out.push_str(&" ".repeat(widths[index].saturating_sub(display_width(value))));
}
let padding = line_width
.saturating_sub(separator_width(wrapped.len()))
.saturating_sub(widths.iter().sum::<usize>());
out.push_str(&" ".repeat(padding));
out.push('\n');
}
}
pub(super) fn separator_width(count: usize) -> usize {
count.saturating_sub(1) * 2
}
pub(super) fn truncate_cell(value: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if display_width(value) <= width {
return value.to_string();
}
let mut out = String::new();
let mut used = 0usize;
for character in value.chars() {
let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
if used + character_width + 1 > width {
break;
}
out.push(character);
used += character_width;
}
let trimmed = out.trim_end().len();
out.truncate(trimmed);
out.push('…');
out
}
#[allow(clippy::too_many_arguments)]
pub(super) fn cell(
column: ListColumn,
issue: &Issue,
resolution: &crate::workflow::CategoryResolution,
today: &str,
derived: Option<&RelationshipCells>,
hierarchy: Option<&BTreeMap<u32, String>>,
wip: Option<&crate::wip::WipSnapshot>,
health: Option<&crate::health::PortableHealth>,
projects: Option<&BTreeMap<u32, String>>,
epics: Option<&BTreeMap<u32, String>>,
) -> String {
match column {
ListColumn::Num => issue.number.to_string(),
ListColumn::Id => issue.id.clone().unwrap_or_else(|| "-".to_string()),
ListColumn::Title => issue.title.clone(),
ListColumn::Status => issue.status.display().to_string(),
ListColumn::Priority => issue.priority.to_string(),
ListColumn::Cycle => issue
.cycles
.as_ref()
.map(|v| v.join(","))
.unwrap_or_default(),
ListColumn::Milestone => issue
.milestones
.as_ref()
.map(|v| v.join(","))
.unwrap_or_default(),
ListColumn::Reviewer => issue.reviewer.clone().unwrap_or_default(),
ListColumn::Start => issue.start_date.clone().unwrap_or_default(),
ListColumn::Due => issue.due_date.clone().unwrap_or_default(),
ListColumn::Overdue => {
if issue.due_date.as_deref().is_some_and(|due| due < today)
&& !crate::workflow::is_completed(issue, resolution)
{
"yes".to_string()
} else {
String::new()
}
}
ListColumn::Category => crate::workflow::category_of(issue, resolution)
.map(|c| c.label())
.unwrap_or("(unmapped)")
.to_string(),
ListColumn::Blocked => match derived {
Some(cells) if !cells.blocked.is_empty() => cells.blocked.clone(),
_ => "-".to_string(),
},
ListColumn::Relations => match derived {
Some(cells) if !cells.relationships.is_empty() => cells.relationships.clone(),
_ => "-".to_string(),
},
ListColumn::Progress => match hierarchy.and_then(|cells| cells.get(&issue.number)) {
Some(text) if !text.is_empty() => text.clone(),
_ => "-".to_string(),
},
ListColumn::Projects => match projects.and_then(|cells| cells.get(&issue.number)) {
Some(text) if !text.is_empty() => text.clone(),
_ => "-".to_string(),
},
ListColumn::Epics => match epics.and_then(|cells| cells.get(&issue.number)) {
Some(text) if !text.is_empty() => text.clone(),
_ => "-".to_string(),
},
ListColumn::Health => health
.and_then(|document| {
document.scope(&crate::health::HealthScope::of_issue(issue.number))
})
.map(|entry| match entry.rollup.as_ref() {
Some(derived) if entry.state != derived.state => {
format!("{}→{}", entry.state, derived.state)
}
_ => entry.state.clone(),
})
.unwrap_or_else(|| "-".to_string()),
ListColumn::Archived => {
if issue.archived {
"ARCHIVED".to_string()
} else {
"-".to_string()
}
}
ListColumn::Wip => wip
.and_then(|snapshot| snapshot.column(issue.status.display()))
.filter(|usage| usage.limit.is_some())
.map_or_else(|| "-".to_string(), crate::wip::ColumnUsage::cell),
}
}
pub(super) fn wrap_cell(value: &str, width: usize) -> Vec<String> {
if value.is_empty() || width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut line = String::new();
for word in value.split_whitespace() {
let word_width = display_width(word);
let candidate_width = display_width(&line) + usize::from(!line.is_empty()) + word_width;
if !line.is_empty() && candidate_width > width {
lines.push(std::mem::take(&mut line));
}
if word_width <= width {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
} else {
let mut chunk = String::new();
for character in word.chars() {
let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
if !chunk.is_empty() && display_width(&chunk) + character_width > width {
lines.push(std::mem::take(&mut chunk));
}
chunk.push(character);
}
line = chunk;
}
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
pub(super) fn display_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListFormat {
Xml,
Yaml,
Toml,
}
impl ListFormat {
pub(super) fn parse(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"xml" => Ok(Self::Xml),
"yml" | "yaml" => Ok(Self::Yaml),
"toml" => Ok(Self::Toml),
other => Err(TixError::InvalidInput(format!(
"unknown list format `{other}`; this surface understands `xml`, `yml` and `toml`"
))),
}
}
}
pub(super) fn render_records(
header: &[String],
rows: &[Vec<String>],
format: ListFormat,
) -> Result<String> {
let records: Vec<IndexMap<String, String>> = rows
.iter()
.map(|row| {
header
.iter()
.zip(row)
.map(|(column, value)| (column.to_ascii_lowercase(), value.clone()))
.collect()
})
.collect();
let document = IndexMap::from([("issues".to_string(), records)]);
match format {
ListFormat::Xml => Ok(render_xml(&document["issues"])),
ListFormat::Yaml => serde_yaml::to_string(&document)
.map_err(|error| TixError::InvalidInput(format!("could not render yaml: {error}"))),
ListFormat::Toml => toml::to_string(&document)
.map_err(|error| TixError::InvalidInput(format!("could not render toml: {error}"))),
}
}
pub(super) fn render_xml(records: &[IndexMap<String, String>]) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<issues>\n");
for record in records {
out.push_str(" <issue>\n");
for (column, value) in record {
out.push_str(" <");
out.push_str(column);
out.push('>');
out.push_str(&escape_xml(value));
out.push_str("</");
out.push_str(column);
out.push_str(">\n");
}
out.push_str(" </issue>\n");
}
out.push_str("</issues>\n");
out
}
pub(super) fn escape_xml(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for character in value.chars() {
match character {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(character),
}
}
out
}
pub fn render_default_table(
issues: &[Issue],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
width: usize,
) -> String {
let columns = default_columns(issues);
let (header, rows) = table_rows(
issues, &columns, resolution, None, None, None, None, None, None,
);
render_table_from(
issues, &columns, group, resolution, width, &header, &rows, None, None, true,
)
}