use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::search::control_token;
use crate::storage::Store;
use indexmap::IndexMap;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const FALLBACK_WIDTH: usize = 0;
#[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 {
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"
))),
}
}
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",
}
}
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)
}
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
}
const RELATIONSHIP_COLUMNS: [ListColumn; 2] = [ListColumn::Blocked, ListColumn::Relations];
pub fn entry_column(config: &crate::config::Config) -> Option<String> {
let inbox = config
.triage
.as_ref()
.and_then(|triage| triage.entry_status.as_deref());
let columns = &config.default_config().columns;
columns
.iter()
.find(|column| Some(column.as_str()) != inbox)
.or_else(|| columns.first())
.cloned()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListScope {
pub status: Option<String>,
pub me: Option<crate::assignee::AuthorIdentity>,
}
pub fn default_scope(config: &crate::config::Config, all: bool) -> ListScope {
if all {
return ListScope::default();
}
ListScope {
status: entry_column(config),
me: crate::assignee::git_identity(),
}
}
fn is_mine(issue: &Issue, me: &crate::assignee::AuthorIdentity) -> bool {
let Some(assignee) = issue.assignee.as_deref() else {
return false;
};
if assignee == me.to_string() {
return true;
}
let handle = assignee.strip_prefix('@').unwrap_or(assignee);
if handle.eq_ignore_ascii_case(&me.name) {
return true;
}
me.email.as_deref().is_some_and(|email| {
handle.eq_ignore_ascii_case(email)
|| handle.eq_ignore_ascii_case(email.split('@').next().unwrap_or_default())
})
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ListSelection<'a> {
pub status: Option<&'a str>,
pub filter: Option<&'a str>,
pub project: Option<&'a str>,
pub epic: Option<&'a str>,
pub all: bool,
}
impl ListSelection<'_> {
fn names_a_selection(&self) -> bool {
self.all
|| self.status.is_some()
|| self.filter.is_some()
|| self.project.is_some()
|| self.epic.is_some()
}
}
pub fn scoped_query(
store: &Store,
selection: &ListSelection<'_>,
with_archived: bool,
) -> Result<Vec<Issue>> {
let scope = default_scope(&store.config, selection.names_a_selection());
let effective = selection.status.map(str::to_string).or(scope.status);
let mut issues = query(store, effective.as_deref(), selection.filter, with_archived)?;
if let Some(me) = scope.me.as_ref() {
issues.retain(|issue| is_mine(issue, me));
}
Ok(issues)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadyOptions {
pub limit: Option<usize>,
}
fn is_startable(
issue: &Issue,
resolution: &crate::workflow::CategoryResolution,
board: &[String],
entry: Option<&str>,
) -> bool {
let Some(category) = crate::workflow::category_of(issue, resolution) else {
return false;
};
if !matches!(
category,
crate::workflow::WorkflowCategory::Backlog | crate::workflow::WorkflowCategory::Unstarted
) {
return false;
}
let Some(entry_index) = entry.and_then(|entry| board.iter().position(|column| column == entry))
else {
return true;
};
board
.iter()
.position(|column| column == issue.status.display())
.is_none_or(|index| index <= entry_index)
}
fn ready_columns(issues: &[Issue]) -> Vec<ListColumn> {
let mut columns = vec![
ListColumn::Num,
ListColumn::Id,
ListColumn::Priority,
ListColumn::Title,
];
if issues.iter().any(|issue| issue.archived) {
columns.push(ListColumn::Archived);
}
columns
}
fn sort_ready(issues: &mut [Issue]) {
issues.sort_by(|a, b| {
priority_rank(&a.priority)
.cmp(&priority_rank(&b.priority))
.then(a.created.cmp(&b.created))
.then(a.number.cmp(&b.number))
});
}
fn priority_rank(priority: &crate::model::Priority) -> u8 {
match priority.to_string().to_ascii_lowercase().as_str() {
"critical" | "urgent" | "blocker" | "blocking" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
"" => 4,
_ => 5,
}
}
pub fn ready(
store: &Store,
columns: Option<&str>,
limit: Option<usize>,
project: Option<&str>,
epic: Option<&str>,
format: Option<&str>,
) -> Result<()> {
run(
store,
None,
None,
project,
epic,
None,
None,
columns,
None,
false,
true,
format,
Some(ReadyOptions { limit }),
)
}
#[allow(clippy::too_many_arguments)]
pub fn run(
store: &Store,
status: Option<&str>,
filter: Option<&str>,
project: Option<&str>,
epic: Option<&str>,
sort: Option<&str>,
group: Option<&str>,
columns: Option<&str>,
show: Option<&str>,
with_archived: bool,
all: bool,
format: Option<&str>,
ready: Option<ReadyOptions>,
) -> Result<()> {
let shows = wants_show(show)?;
let show_relationships = shows.contains(&"relationships");
let show_progress = shows.contains(&"progress");
let format = format.map(ListFormat::parse).transpose()?;
let mut issues = scoped_query(
store,
&ListSelection {
status,
filter,
project,
epic,
all,
},
with_archived,
)?;
let (_, resolution) = crate::workflow::active_resolution(&store.config);
if let Some(options) = ready {
let cells = relationship_cells(store)?;
issues.retain(|issue| {
cells
.get(&issue.number)
.is_none_or(|entry| entry.blocked.is_empty())
});
let board = &store.config.default_config().columns;
let entry = entry_column(&store.config);
issues.retain(|issue| is_startable(issue, &resolution, board, entry.as_deref()));
sort_ready(&mut issues);
if let Some(limit) = options.limit {
issues.truncate(limit);
}
}
if let Some(project) = project {
let reference = crate::projects::resolve_project_id(&store.config, project)
.map_or_else(|_| project.to_string(), |id| id.to_string());
let criterion = crate::search::StructuredFilter {
project: Some(reference),
..crate::search::StructuredFilter::default()
};
issues.retain(|issue| criterion.project_matches(issue));
}
if let Some(epic) = epic {
let reference = crate::epics::dereference_needle(&store.config.epics, epic);
let criterion = crate::search::StructuredFilter {
epic: Some(reference),
..crate::search::StructuredFilter::default()
};
issues.retain(|issue| criterion.epic_matches(issue));
}
let sort_key = sort
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "sort")));
let group_key = group
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "group")));
let by_project =
sort_key.as_deref() == Some("project") || group_key.as_deref() == Some("project");
let project_groups = if by_project {
Some(project_group_labels(store, &issues))
} else {
None
};
let by_epic = sort_key.as_deref() == Some("epic") || group_key.as_deref() == Some("epic");
let epic_groups = if by_epic {
Some(epic_group_labels(store, &issues))
} else {
None
};
if by_project {
sort_by_project(store, &mut issues, project_groups.as_ref().unwrap());
} else if by_epic {
sort_by_epic(store, &mut issues, epic_groups.as_ref().unwrap());
} else if ready.is_none() {
sort_issues(&mut issues, sort_key.as_deref());
}
let done_column =
crate::workflow::completion_column_or_default(store.config.default_config(), &store.config);
let mut columns = columns.map(parse_columns).transpose()?.unwrap_or_else(|| {
if ready.is_some() {
ready_columns(&issues)
} else {
default_columns(&issues)
}
});
append_show_columns(&mut columns, &shows);
let mut derived: Option<BTreeMap<u32, RelationshipCells>> = None;
let wants_relationship_column = RELATIONSHIP_COLUMNS.iter().any(|c| columns.contains(c));
if show_relationships || wants_relationship_column {
for column in RELATIONSHIP_COLUMNS {
if !columns.contains(&column) {
columns.push(column);
}
}
derived = Some(relationship_cells(store)?);
}
let mut hierarchy: Option<BTreeMap<u32, String>> = None;
if wants_progress_data(show_progress, &columns) {
hierarchy = Some(hierarchy_cells(store, &done_column)?);
}
let mut projects: Option<BTreeMap<u32, String>> = None;
if columns.contains(&ListColumn::Projects) {
projects = Some(project_cells(store, &issues)?);
}
let mut epics: Option<BTreeMap<u32, String>> = None;
if columns.contains(&ListColumn::Epics) {
epics = Some(crate::commands::view::epic_cells(store, &issues)?);
}
let mut wip: Option<crate::wip::WipSnapshot> = None;
if columns.contains(&ListColumn::Wip) {
wip = Some(crate::wip::WipSnapshot::build(
store.config.default_config(),
&issues,
));
}
let mut health: Option<crate::health::PortableHealth> = None;
if columns.contains(&ListColumn::Health) {
health = Some(crate::commands::export::run_health_core(&store.config)?);
}
let width = terminal_width();
let (header, rows) = table_rows(
&issues,
&columns,
&resolution,
derived.as_ref(),
hierarchy.as_ref(),
wip.as_ref(),
health.as_ref(),
projects.as_ref(),
epics.as_ref(),
);
let text = match format {
Some(format) => render_records(&header, &rows, format)?,
None => render_table_from(
&issues,
&columns,
group_key.as_deref(),
&resolution,
width,
&header,
&rows,
project_groups.as_ref(),
epic_groups.as_ref(),
true,
),
};
print!("{text}");
Ok(())
}
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);
}
}
fn wants_progress_data(show_progress: bool, columns: &[ListColumn]) -> bool {
show_progress || columns.contains(&ListColumn::Progress)
}
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)
}
fn hierarchy_cells(store: &Store, done_column: &str) -> Result<BTreeMap<u32, String>> {
let (index, issues, _) = crate::commands::parent::source(store)?;
let mut out = BTreeMap::new();
for issue in &issues {
let facts = crate::commands::parent::facts_for(&index, &issues, issue.number, done_column);
if !facts.has_children() {
continue;
}
out.insert(
issue.number,
format!(
"{} {}/{}",
facts.rollup.direct_children,
facts.rollup.completed_children,
facts.rollup.progress.total
),
);
}
Ok(out)
}
pub(crate) fn project_cells(store: &Store, issues: &[Issue]) -> Result<BTreeMap<u32, String>> {
let snapshot = store.list()?;
let (_index, reports) = crate::commands::view::project_reports(store, &snapshot)?;
let mut out = BTreeMap::new();
for issue in issues {
let Some(ids) = issue.projects.as_ref().filter(|ids| !ids.is_empty()) else {
continue;
};
let mut rendered: Vec<String> = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for raw in ids {
if seen.contains(&raw.as_str()) {
continue;
}
seen.push(raw.as_str());
match crate::config::ProjectId::new(raw)
.ok()
.and_then(|id| reports.get(&id))
{
Some(report) => rendered.push(format!(
"{} {}/{}{}",
crate::projects::project_display(&report.project_id, &report.name),
report.progress.done,
report.progress.total,
crate::commands::view::health_suffix(report),
)),
None => rendered.push(format!("{raw} (missing reference)")),
}
}
out.insert(issue.number, rendered.join("; "));
}
Ok(out)
}
fn relationship_cells(store: &Store) -> Result<BTreeMap<u32, RelationshipCells>> {
let (issues, done_column) = store.graph_source()?;
let views: Vec<crate::relationships::IssueView<'_>> =
issues.iter().map(Issue::issue_view).collect();
let graph = crate::relationships::RelationshipGraph::new(&views, &done_column);
let mut out = BTreeMap::new();
for issue in &issues {
let state = graph.blocked_state(issue.number);
let relationships = graph
.relationships_for(issue.number)
.into_iter()
.map(|rel| format!("{} -> #{}", rel.kind, rel.to))
.collect::<Vec<_>>()
.join(", ");
out.insert(
issue.number,
RelationshipCells {
blocked: state.reason,
relationships,
},
);
}
Ok(out)
}
fn terminal_width() -> usize {
if std::io::stdout().is_terminal() {
crossterm::terminal::size()
.ok()
.map(|(width, _)| width as usize)
.filter(|width| *width > 0)
.unwrap_or(FALLBACK_WIDTH)
} else {
FALLBACK_WIDTH
}
}
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)]
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)]
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
}
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');
}
}
fn separator_width(count: usize) -> usize {
count.saturating_sub(1) * 2
}
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)]
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),
}
}
fn group_value(issue: &Issue, group: &str) -> String {
let values = match group {
"cycle" => issue.cycles.as_ref(),
"milestone" => issue.milestones.as_ref(),
_ => None,
};
values
.and_then(|values| values.first().cloned())
.unwrap_or_else(|| "(none)".to_string())
}
fn group_key(
issue: &Issue,
group: &str,
project_groups: Option<&BTreeMap<u32, String>>,
epic_groups: Option<&BTreeMap<u32, String>>,
) -> String {
match group {
"project" => project_groups
.and_then(|groups| groups.get(&issue.number).cloned())
.unwrap_or_else(|| crate::projects::NO_PROJECT_BUCKET.to_string()),
"epic" => epic_groups
.and_then(|groups| groups.get(&issue.number).cloned())
.unwrap_or_else(|| crate::search::NO_EPIC_BUCKET.to_string()),
_ => group_value(issue, group),
}
}
fn project_group_labels(store: &Store, issues: &[Issue]) -> BTreeMap<u32, String> {
let index = crate::projects::ProjectIndex::build(&store.config, issues);
let memberships = index.resolved_memberships();
issues
.iter()
.map(|issue| {
let ids = memberships.get(&issue.number).cloned().unwrap_or_default();
(issue.number, crate::projects::project_bucket_label(&ids))
})
.collect()
}
fn epic_group_labels(store: &Store, issues: &[Issue]) -> BTreeMap<u32, String> {
let memberships = crate::epics::memberships_by_issue(&store.config, issues);
issues
.iter()
.map(|issue| {
let ids = memberships.get(&issue.number).cloned().unwrap_or_default();
(issue.number, crate::search::epic_bucket_label(&ids))
})
.collect()
}
fn sort_by_project(store: &Store, issues: &mut [Issue], groups: &BTreeMap<u32, String>) {
let declaration: Vec<&str> = store.config.projects.keys().map(String::as_str).collect();
let rank = |issue: &Issue| -> usize {
match groups.get(&issue.number).map(String::as_str) {
Some(crate::projects::MULTIPLE_PROJECTS_BUCKET) => usize::MAX - 1,
Some(crate::projects::NO_PROJECT_BUCKET) | None => usize::MAX,
Some(id) => declaration
.iter()
.position(|candidate| *candidate == id)
.unwrap_or(usize::MAX),
}
};
issues.sort_by(|a, b| rank(a).cmp(&rank(b)).then(a.number.cmp(&b.number)));
}
fn sort_by_epic(store: &Store, issues: &mut [Issue], groups: &BTreeMap<u32, String>) {
let declaration: Vec<&str> = store.config.epics.keys().map(String::as_str).collect();
let rank = |issue: &Issue| -> usize {
match groups.get(&issue.number).map(String::as_str) {
Some(crate::search::MULTIPLE_EPICS_BUCKET) => usize::MAX - 1,
Some(crate::search::NO_EPIC_BUCKET) | None => usize::MAX,
Some(id) => declaration
.iter()
.position(|candidate| *candidate == id)
.unwrap_or(usize::MAX),
}
};
issues.sort_by(|a, b| rank(a).cmp(&rank(b)).then(a.number.cmp(&b.number)));
}
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
}
fn display_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListFormat {
Xml,
Yaml,
Toml,
}
impl ListFormat {
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`"
))),
}
}
}
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}"))),
}
}
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
}
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,
)
}
pub fn query(
store: &Store,
status: Option<&str>,
filter: Option<&str>,
with_archived: bool,
) -> Result<Vec<Issue>> {
let mut issues = if with_archived {
store.list_all()?
} else {
store.list()?
};
if let Some(col) = status {
issues.retain(|i| i.status.is(col));
}
if let Some(filter) = filter {
let (filter, criteria) = crate::search::resolve_filter_criteria(store, filter)?;
let (_, resolution) = crate::workflow::active_resolution(&store.config);
issues.retain(|i| {
crate::search::matches_issue(&filter, &criteria, i, &i.to_file_contents(), &resolution)
});
}
Ok(issues)
}
fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
match sort.unwrap_or("created") {
"due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
"start" => issues.sort_by(|a, b| {
a.start_date
.cmp(&b.start_date)
.then(a.number.cmp(&b.number))
}),
"reviewer" => {
issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
}
"cycle" => issues.sort_by(|a, b| {
a.cycles
.as_ref()
.and_then(|v| v.first())
.cmp(&b.cycles.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"milestone" => issues.sort_by(|a, b| {
a.milestones
.as_ref()
.and_then(|v| v.first())
.cmp(&b.milestones.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"status" => issues.sort_by(|a, b| {
a.status
.display()
.cmp(b.status.display())
.then(a.number.cmp(&b.number))
}),
"priority" => issues.sort_by(|a, b| {
a.priority
.to_string()
.cmp(&b.priority.to_string())
.then(a.number.cmp(&b.number))
}),
_ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, number: u32, title: &str, status: &str) -> Issue {
let mut issue = Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: Status::new(status.to_string()),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".to_string()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".to_string(),
type_: None,
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "body".to_string(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
issue
}
#[test]
fn columns_are_case_insensitive_and_ordered() {
let parsed = parse_columns("PrIoRiTy,TITLE,num").unwrap();
assert_eq!(
parsed,
vec![ListColumn::Priority, ListColumn::Title, ListColumn::Num]
);
}
#[test]
fn invalid_columns_are_rejected() {
for value in ["", "title,", "title,title", "unknown"] {
assert!(parse_columns(value).is_err(), "{value:?} should fail");
}
}
#[test]
fn selected_order_and_empty_output() {
let _store = make_store().1;
let columns = parse_columns("status,title").unwrap();
let store = make_store().1;
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let output = render_table(&[], &columns, None, &resolution, 40);
let header = output.lines().next().unwrap();
assert!(header.starts_with("STATUS TITLE"));
assert_eq!(UnicodeWidthStr::width(header), 40);
assert_eq!(output.lines().count(), 1);
}
#[test]
fn title_receives_space_and_secondary_cells_wrap() {
let store = make_store().1;
let issue = seed(&store, 1, "A long title that wraps", "in progress");
let mut issue = issue;
issue.reviewer = Some("reviewer with a long name".to_string());
let columns = parse_columns("num,title,status,priority,reviewer").unwrap();
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let output = render_table(&[issue], &columns, None, &resolution, 45);
assert!(output.lines().count() > 2);
assert!(
output
.lines()
.all(|line| UnicodeWidthStr::width(line) == 45)
);
}
#[test]
fn query_without_filter_returns_every_issue() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "First", "todo");
seed(&store, 2, "Second", "done");
assert_eq!(query(&store, None, None, false).unwrap().len(), 2);
}
#[test]
fn with_archived_shows_the_row_marked_and_composes_with_the_filter() {
let (_tmp, store) = make_store();
seed(&store, 1, "Keep", "todo");
seed(&store, 2, "Old", "todo");
store.archive("2").unwrap();
let visible = query(&store, None, None, false).unwrap();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].number, 1);
let all = query(&store, None, None, true).unwrap();
assert_eq!(all.len(), 2);
assert!(all.iter().any(|issue| issue.number == 2 && issue.archived));
let archived_only = query(&store, None, Some("archived:true"), true).unwrap();
assert_eq!(numbers(&archived_only), vec![2]);
let active_only = query(&store, None, Some("archived:false"), true).unwrap();
assert_eq!(numbers(&active_only), vec![1]);
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let table = render_default_table(&all, None, &resolution, 0);
assert!(table.contains("ARCHIVED"), "marker column missing: {table}");
}
#[test]
fn progress_renders_the_count_and_rollup_for_a_parent_and_a_dash_for_a_leaf() {
let store = make_store().1;
let parent = seed(&store, 1, "Epic", "todo");
let mut child = seed(&store, 2, "Child", "done");
child.parent = Some(1);
fs::write(
store.issues_dir.join(&child.filename),
child.to_file_contents(),
)
.unwrap();
let leaf = seed(&store, 3, "Leaf", "todo");
let done_column = crate::workflow::completion_column_or_default(
store.config.default_config(),
&store.config,
);
let cells = hierarchy_cells(&store, &done_column).unwrap();
assert_eq!(
cells.get(&1).map(String::as_str),
Some("1 1/1"),
"a parent renders its completed/total rollup"
);
assert_eq!(cells.get(&3), None, "a leaf has no hierarchy cell");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = parse_columns("num,progress").unwrap();
let output = render_table_with(
&[parent, leaf],
&columns,
None,
&resolution,
40,
None,
Some(&cells),
None,
None,
None,
None,
None,
None,
);
let parent_row = output
.lines()
.find(|l| l.trim_start().starts_with('1'))
.unwrap();
let leaf_row = output
.lines()
.find(|l| l.trim_start().starts_with('3'))
.unwrap();
assert!(
parent_row.contains("1/1"),
"parent row shows the rollup: {output:?}"
);
assert!(
leaf_row.trim_end().ends_with('-'),
"a leaf renders `-`, not `0`: {output:?}"
);
}
#[test]
fn a_progress_column_requested_by_name_renders_data_rather_than_a_placeholder() {
let by_name = parse_columns("num,progress").unwrap();
assert!(
wants_progress_data(false, &by_name),
"a progress column named in --columns fetches the data"
);
let plain = parse_columns("num,title").unwrap();
assert!(
wants_progress_data(true, &plain),
"--show progress fetches the data"
);
assert!(
!wants_progress_data(false, &plain),
"neither spelling: no hierarchy fetch"
);
}
fn numbers(issues: &[Issue]) -> Vec<u32> {
issues.iter().map(|issue| issue.number).collect()
}
fn store_with_projects(projects: &[(&str, &str)]) -> (TempDir, Store) {
let (tmp, mut store) = make_store();
for (id, name) in projects {
store.config.projects.insert(
(*id).to_string(),
crate::config::ProjectConfiguration {
name: (*name).to_string(),
..crate::config::ProjectConfiguration::default()
},
);
}
(tmp, store)
}
fn rewrite(store: &Store, mut issue: Issue, projects: &[&str]) {
issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
write_back(store, &issue);
}
fn write_back(store: &Store, issue: &Issue) {
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
}
#[test]
fn t018_the_project_column_shows_both_projects_and_a_dash_when_none() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform"), ("p-b", "Docs")]);
rewrite(&store, seed(&store, 1, "Shared", "todo"), &["p-a", "p-b"]);
seed(&store, 2, "None", "todo");
let issues = query(&store, None, None, false).unwrap();
let cells = project_cells(&store, &issues).unwrap();
let cell = cells.get(&1).expect("issue #1 belongs to two projects");
assert!(cell.contains("Platform (p-a)"), "{cell}");
assert!(cell.contains("Docs (p-b)"), "{cell}");
assert!(
cell.contains("done, overdue 0, blocked 0, unassigned 0"),
"the health context is rendered: {cell}"
);
assert_eq!(cells.get(&2), None, "an unassociated issue has no cell");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = parse_columns("num,projects").unwrap();
let table = render_table_with(
&issues,
&columns,
None,
&resolution,
0,
None,
None,
None,
None,
Some(&cells),
None,
None,
None,
);
assert!(table.contains("PROJECTS"), "{table}");
assert!(
table
.lines()
.any(|l| l.trim_start().starts_with('2') && l.trim_end().ends_with('-')),
"an unassociated issue renders `-`: {table}"
);
}
#[test]
fn t018_a_missing_reference_is_visible_in_the_project_cell() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform")]);
rewrite(
&store,
seed(&store, 1, "Dangling", "todo"),
&["p-a", "p-gone"],
);
let issues = query(&store, None, None, false).unwrap();
let cells = project_cells(&store, &issues).unwrap();
let cell = cells.get(&1).unwrap();
assert!(
cell.contains("p-gone (missing reference)"),
"a dangling association is named: {cell}"
);
assert!(cell.contains("Platform (p-a)"), "{cell}");
}
#[test]
fn t018_the_project_cells_are_deterministic() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform"), ("p-b", "Docs")]);
rewrite(&store, seed(&store, 1, "Shared", "todo"), &["p-b", "p-a"]);
rewrite(&store, seed(&store, 2, "One", "todo"), &["p-a"]);
let issues = query(&store, None, None, false).unwrap();
let first = project_cells(&store, &issues).unwrap();
assert_eq!(
first.get(&1).map(String::as_str),
Some(
"Docs (p-b) 0/1 done, overdue 0, blocked 0, unassigned 0; Platform (p-a) 0/2 done, overdue 0, blocked 0, unassigned 0"
),
"associations keep the issue's own order, projects in declaration order"
);
for _ in 0..3 {
assert_eq!(
project_cells(&store, &issues).unwrap(),
first,
"repeated reads are identical"
);
}
}
#[test]
fn t018_the_show_progress_column_set_is_pinned() {
let (_tmp, store) = make_store();
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let headers = |columns: &[ListColumn]| -> Vec<&'static str> {
columns.iter().map(|column| column.header()).collect()
};
let by_name = parse_columns("num,progress").unwrap();
assert_eq!(
headers(&by_name),
["NUM", "CHILDREN"],
"`--columns progress` is the CHILDREN data only"
);
let mut via_show = default_columns(&[]);
append_show_columns(&mut via_show, &["progress"]);
assert_eq!(
headers(&via_show),
["NUM", "ID", "TITLE", "CHILDREN", "PROJECTS", "EPICS"],
"`--show progress` is CHILDREN then PROJECTS then EPICS (WP04 T018), \
appended to the compact default set (#1711)"
);
let issue = seed(&store, 1, "Plain", "todo");
let mut project_free = default_columns(&[]);
append_show_columns(&mut project_free, &["progress"]);
let table = render_table_with(
&[issue],
&project_free,
None,
&resolution,
0,
None,
None,
None,
None,
None,
None,
None,
None,
);
let row = table.lines().nth(1).unwrap();
assert!(table.contains("PROJECTS"), "{table}");
assert!(
row.trim_end().ends_with('-'),
"a project-free row renders `-` in PROJECTS: {table}"
);
}
#[test]
fn t018_a_filtered_listing_reports_the_same_project_figures_as_tix_show() {
let (_tmp, store) = store_with_projects(&[("p-a", "Platform")]);
let mut done = seed(&store, 1, "Done", "done");
done.projects = Some(vec!["p-a".into()]);
write_back(&store, &done);
rewrite(&store, seed(&store, 2, "Open", "todo"), &["p-a"]);
let mut unassigned = seed(&store, 3, "Unassigned", "todo");
unassigned.assignee = None;
unassigned.projects = Some(vec!["p-a".into()]);
write_back(&store, &unassigned);
let filtered = query(&store, Some("todo"), None, false).unwrap();
assert_eq!(
numbers(&filtered),
vec![2, 3],
"precondition: only the open rows are listed"
);
let cells = project_cells(&store, &filtered).unwrap();
let figure = "1/3 done, overdue 0, blocked 0, unassigned 1";
let cell = cells.get(&2).expect("a listed row in the project");
assert!(
cell.contains(figure),
"the filtered listing reports the **full** project: {cell}"
);
let loaded = crate::commands::view::get(&store, "2").unwrap();
let block = crate::commands::view::projects_block(&store, &loaded).unwrap();
assert!(block.contains(figure), "`tix show` must agree: {block}");
}
fn store_with_epics(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
let (tmp, mut store) = make_store();
for (id, name, weight, parent) in epics {
store.config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
weight: *weight,
parent: parent.map(str::to_string),
..crate::config::EpicConfiguration::default()
},
);
}
(tmp, store)
}
fn set_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
write_back(store, &issue);
issue
}
#[test]
fn t018_the_epic_column_shows_every_reference_and_a_dash_when_none() {
let (_tmp, store) = store_with_epics(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
set_epics(
&store,
seed(&store, 1, "Shared", "todo"),
&["e-aaa111", "e-bbb222"],
);
seed(&store, 2, "None", "todo");
let issues = query(&store, None, None, false).unwrap();
let cells = crate::commands::view::epic_cells(&store, &issues).unwrap();
let cell = cells.get(&1).expect("#1 references two epics");
assert!(cell.contains("Alpha (e-aaa111)"), "{cell}");
assert!(cell.contains("Beta (e-bbb222)"), "{cell}");
assert!(
cell.contains("Alpha (e-aaa111) 0/1 done"),
"the figures are read from the service: {cell}"
);
assert_eq!(cells.get(&2), None, "an unreferenced issue has no cell");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = parse_columns("num,epics").unwrap();
let table = render_table_with(
&issues,
&columns,
None,
&resolution,
0,
None,
None,
None,
None,
None,
Some(&cells),
None,
None,
);
assert!(table.contains("EPICS"), "{table}");
assert!(
table
.lines()
.any(|l| l.trim_start().starts_with('2') && l.trim_end().ends_with('-')),
"an unreferenced issue renders `-`: {table}"
);
}
#[test]
fn t018_a_missing_epic_reference_is_visible_in_the_cell() {
let (_tmp, store) = store_with_epics(&[("e-aaa111", "Alpha", None, None)]);
set_epics(
&store,
seed(&store, 1, "Dangling", "todo"),
&["e-aaa111", "e-gone1"],
);
let issues = query(&store, None, None, false).unwrap();
let cells = crate::commands::view::epic_cells(&store, &issues).unwrap();
let cell = cells.get(&1).unwrap();
assert!(
cell.contains("e-gone1 (missing reference)"),
"a dangling reference is named: {cell}"
);
assert!(cell.contains("Alpha (e-aaa111)"), "{cell}");
}
#[test]
fn t018_the_epic_cells_are_deterministic() {
let (_tmp, store) = store_with_epics(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
set_epics(
&store,
seed(&store, 1, "Shared", "todo"),
&["e-bbb222", "e-aaa111"],
);
let issues = query(&store, None, None, false).unwrap();
let first = crate::commands::view::epic_cells(&store, &issues).unwrap();
let cell = first.get(&1).unwrap();
assert!(
cell.starts_with("Beta (e-bbb222)"),
"the issue's own reference order is kept: {cell}"
);
assert!(cell.ends_with("blocked 0"), "{cell}");
for _ in 0..3 {
assert_eq!(
crate::commands::view::epic_cells(&store, &issues).unwrap(),
first,
"repeated reads are identical"
);
}
}
#[test]
fn t018_epic_is_a_recognised_column_spelling() {
assert_eq!(
parse_columns("epics").unwrap(),
vec![ListColumn::Epics],
"the plural is the column name"
);
assert_eq!(
parse_columns("epic").unwrap(),
vec![ListColumn::Epics],
"the singular parses to the same column"
);
}
#[test]
fn the_canonical_id_is_a_column_and_a_default_one() {
assert_eq!(parse_columns("id").unwrap(), vec![ListColumn::Id]);
assert_eq!(
parse_columns("hash").unwrap(),
vec![ListColumn::Id],
"the word the request used reaches the same column"
);
assert_eq!(
default_columns(&[]),
vec![ListColumn::Num, ListColumn::Id, ListColumn::Title],
"the identity pair and the title, nothing else"
);
}
#[test]
fn truncate_cell_fits_the_width_and_marks_what_it_dropped() {
assert_eq!(truncate_cell("short", 10), "short");
assert_eq!(truncate_cell("exactly-10", 10), "exactly-10");
assert_eq!(truncate_cell("anything", 0), "", "no width, no cell");
let cut = truncate_cell("a very long title indeed", 8);
assert!(cut.ends_with('…'), "a cut is visible: {cut:?}");
assert!(
display_width(&cut) <= 8,
"and fits its column: {cut:?} is {} wide",
display_width(&cut)
);
assert!(
!cut.trim_end().ends_with(' '),
"a cut that landed after a space does not print `word …`: {cut:?}"
);
let wide = truncate_cell("日本語のタイトル", 4);
assert!(display_width(&wide) <= 4, "{wide:?}");
}
#[test]
fn the_compact_table_is_one_line_per_issue_at_the_given_width() {
let store = make_store().1;
let issue = seed(
&store,
1,
"A title long enough that it cannot possibly fit in one row",
"todo",
);
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = default_columns(std::slice::from_ref(&issue));
let (header, rows) = table_rows(
std::slice::from_ref(&issue),
&columns,
&resolution,
None,
None,
None,
None,
None,
None,
);
let rendered = render_table_from(
std::slice::from_ref(&issue),
&columns,
None,
&resolution,
40,
&header,
&rows,
None,
None,
true,
);
assert_eq!(
rendered.lines().count(),
2,
"a header and one row: {rendered}"
);
for line in rendered.lines() {
assert_eq!(UnicodeWidthStr::width(line), 40, "{line:?}");
}
}
#[test]
fn the_compact_table_keeps_the_whole_title_when_no_width_is_known() {
let store = make_store().1;
let title = "A title long enough that no forty column cap could ever hold it";
let issue = seed(&store, 1, title, "todo");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let columns = default_columns(std::slice::from_ref(&issue));
let (header, rows) = table_rows(
std::slice::from_ref(&issue),
&columns,
&resolution,
None,
None,
None,
None,
None,
None,
);
let rendered = render_table_from(
std::slice::from_ref(&issue),
&columns,
None,
&resolution,
0,
&header,
&rows,
None,
None,
true,
);
assert!(rendered.contains(title), "{rendered}");
assert_eq!(rendered.lines().count(), 2, "{rendered}");
}
#[test]
fn the_entry_column_skips_the_triage_inbox() {
let starter = crate::config::Config::starter();
assert_eq!(entry_column(&starter).as_deref(), Some("todo"));
let bare = crate::config::Config::default();
assert_eq!(bare.triage, None);
assert_eq!(entry_column(&bare).as_deref(), Some("todo"));
let mut inbox_only = crate::config::Config::starter();
inbox_only.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["triage".to_string()],
..crate::config::BoardConfiguration::default()
},
);
assert_eq!(entry_column(&inbox_only).as_deref(), Some("triage"));
}
#[test]
fn the_default_scope_is_dropped_by_any_selection_of_your_own() {
assert!(!ListSelection::default().names_a_selection());
for selection in [
ListSelection {
all: true,
..ListSelection::default()
},
ListSelection {
status: Some("doing"),
..ListSelection::default()
},
ListSelection {
filter: Some("label:bug"),
..ListSelection::default()
},
ListSelection {
project: Some("p-1"),
..ListSelection::default()
},
ListSelection {
epic: Some("e-1"),
..ListSelection::default()
},
] {
assert!(
selection.names_a_selection(),
"a named selection replaces the scope: {selection:?}"
);
}
}
#[test]
fn is_mine_matches_the_stored_identity_and_its_handles() {
let me = crate::assignee::AuthorIdentity {
name: "Herbert Maschke".to_string(),
email: Some("thyseus@pm.me".to_string()),
};
let assigned = |value: Option<&str>| {
let mut issue = seed(&make_store().1, 1, "T", "todo");
issue.assignee = value.map(str::to_string);
issue
};
for stored in [
"Herbert Maschke <thyseus@pm.me>",
"Herbert Maschke",
"@Herbert Maschke",
"thyseus@pm.me",
"thyseus",
] {
assert!(is_mine(&assigned(Some(stored)), &me), "{stored:?}");
}
for stored in [None, Some("alice"), Some("alice@example.com")] {
assert!(!is_mine(&assigned(stored), &me), "{stored:?}");
}
}
#[test]
fn ready_orders_by_priority_then_age_then_number() {
let store = make_store().1;
let with = |number: u32, priority: &str, created: &str| {
let mut issue = seed(&store, number, "T", "todo");
issue.priority = priority.parse().unwrap();
issue.created = created.to_string();
issue
};
let mut issues = vec![
with(1, "low", "2026-01-01"),
with(2, "", "2026-01-01"),
with(3, "high", "2026-02-01"),
with(4, "high", "2026-01-01"),
with(5, "someday", "2026-01-01"),
with(6, "medium", "2026-01-01"),
];
sort_ready(&mut issues);
assert_eq!(
issues.iter().map(|issue| issue.number).collect::<Vec<_>>(),
vec![4, 3, 6, 1, 2, 5],
"high (oldest first), medium, low, unset, then the unknown word"
);
}
#[test]
fn records_carry_the_displayed_columns_in_every_format() {
let header = vec!["NUM".to_string(), "TITLE".to_string()];
let rows = vec![
vec!["1".to_string(), "Alpha & <Beta>".to_string()],
vec!["2".to_string(), String::new()],
];
let toml = render_records(&header, &rows, ListFormat::Toml).unwrap();
assert!(toml.contains("[[issues]]"), "{toml}");
assert!(toml.contains(r#"title = "Alpha & <Beta>""#), "{toml}");
assert_eq!(
toml.matches("[[issues]]").count(),
2,
"one record per issue: {toml}"
);
let yaml = render_records(&header, &rows, ListFormat::Yaml).unwrap();
assert!(yaml.starts_with("issues:\n"), "{yaml}");
assert!(
yaml.contains(" title: ''"),
"an empty cell is present and empty: {yaml}"
);
let xml = render_records(&header, &rows, ListFormat::Xml).unwrap();
assert!(xml.contains("<num>1</num>"), "{xml}");
assert!(
xml.contains("<title>Alpha & <Beta></title>"),
"values are escaped, so the document parses: {xml}"
);
assert!(ListFormat::parse("YAML").is_ok(), "case-insensitive");
for value in ["json", "csv", ""] {
let error = ListFormat::parse(value).unwrap_err().to_string();
assert!(error.contains("unknown list format"), "{value}: {error}");
}
}
}