use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::search::control_token;
use crate::storage::Store;
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,
Title,
Status,
Priority,
Cycle,
Milestone,
Reviewer,
Start,
Due,
Overdue,
Blocked,
Relations,
Progress,
Category,
Archived,
}
#[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),
"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),
"category" => Ok(Self::Category),
"archived" => Ok(Self::Archived),
_ => Err(TixError::InvalidInput(format!(
"unknown list column `{value}`; expected one of num,title,status,priority,cycle,milestone,reviewer,start,due,overdue,blocked,relations,progress,category,archived"
))),
}
}
fn header(self) -> &'static str {
match self {
Self::Num => "NUM",
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::Category => "CATEGORY",
Self::Archived => "ARCHIVED",
}
}
fn cap(self) -> usize {
match self {
Self::Num => 6,
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::Category => 14,
Self::Archived => 8,
}
}
}
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 expanded = issues.iter().any(|issue| {
issue.cycles.as_ref().is_some_and(|v| !v.is_empty())
|| issue.milestones.as_ref().is_some_and(|v| !v.is_empty())
|| issue.reviewer.as_deref().is_some_and(|v| !v.is_empty())
|| issue.start_date.is_some()
|| issue.due_date.is_some()
});
let mut columns = vec![
ListColumn::Num,
ListColumn::Title,
ListColumn::Status,
ListColumn::Priority,
];
if expanded {
columns.extend([
ListColumn::Cycle,
ListColumn::Milestone,
ListColumn::Reviewer,
ListColumn::Start,
ListColumn::Due,
ListColumn::Overdue,
]);
}
if issues.iter().any(|issue| issue.archived) {
columns.push(ListColumn::Archived);
}
columns
}
const RELATIONSHIP_COLUMNS: [ListColumn; 2] = [ListColumn::Blocked, ListColumn::Relations];
#[allow(clippy::too_many_arguments)]
pub fn run(
store: &Store,
status: Option<&str>,
filter: Option<&str>,
sort: Option<&str>,
group: Option<&str>,
columns: Option<&str>,
show: Option<&str>,
with_archived: bool,
) -> Result<()> {
let shows = wants_show(show)?;
let show_relationships = shows.contains(&"relationships");
let show_progress = shows.contains(&"progress");
let show_category = shows.contains(&"category");
let mut issues = query(store, status, filter, with_archived)?;
let sort_key = sort
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "sort")));
sort_issues(&mut issues, sort_key.as_deref());
let (_, resolution) = crate::workflow::active_resolution(&store.config);
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(|| default_columns(&issues));
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 show_progress || columns.contains(&ListColumn::Progress) {
if !columns.contains(&ListColumn::Progress) {
columns.push(ListColumn::Progress);
}
hierarchy = Some(hierarchy_cells(store, &done_column)?);
}
if show_category && !columns.contains(&ListColumn::Category) {
columns.push(ListColumn::Category);
}
let group_key = group
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "group")));
let width = terminal_width();
print!(
"{}",
render_table_with(
&issues,
&columns,
group_key.as_deref(),
&resolution,
width,
derived.as_ref(),
hierarchy.as_ref(),
)
);
Ok(())
}
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",
other => {
return Err(TixError::InvalidInput(format!(
"unknown --show value `{other}`; this surface understands \
`relationships`, `progress` and `category`"
)));
}
};
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)
}
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)
}
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>>,
) -> 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))
.collect()
})
.collect();
let natural: Vec<usize> = columns
.iter()
.enumerate()
.map(|(index, column)| {
rows.iter()
.map(|row| display_width(&row[index]))
.chain(std::iter::once(display_width(column.header())))
.max()
.unwrap_or(1)
.min(column.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 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(1));
widths[index] = take.max(1);
remaining = remaining.saturating_sub(widths[index]);
}
if let Some(title) = columns
.iter()
.position(|column| *column == ListColumn::Title)
{
widths[title] = remaining;
}
let line_width = width;
let mut out = String::new();
let header: Vec<String> = columns
.iter()
.map(|column| column.header().to_string())
.collect();
render_row(&mut out, &header, &widths, line_width);
if let Some(group) = group.filter(|group| *group == "cycle" || *group == "milestone") {
let mut progress: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for issue in issues {
let key = group_value(issue, group);
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 in issues {
let key = group_value(issue, group);
if current != key {
current = key.clone();
let (done, total) = progress.get(&key).copied().unwrap_or((0, 0));
let pct = if total == 0 { 0 } else { done * 100 / total };
out.push_str(&format!(
"-- {group}: {key} ({done}/{total} done, {pct}%) --\n"
));
}
let index = issues
.iter()
.position(|candidate| std::ptr::eq(candidate, issue))
.unwrap();
render_row(&mut out, &rows[index], &widths, line_width);
}
} else {
for row in &rows {
render_row(&mut out, row, &widths, line_width);
}
}
out
}
fn render_row(out: &mut String, cells: &[String], widths: &[usize], line_width: usize) {
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
}
#[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>>,
) -> String {
match column {
ListColumn::Num => issue.number.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::Archived => {
if issue.archived {
"ARCHIVED".to_string()
} else {
"-".to_string()
}
}
}
}
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 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)
}
pub fn render_default_table(
issues: &[Issue],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
width: usize,
) -> String {
render_table(issues, &default_columns(issues), group, resolution, width)
}
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 {
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,
parent: 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}");
}
fn numbers(issues: &[Issue]) -> Vec<u32> {
issues.iter().map(|issue| issue.number).collect()
}
}