tix/commands/list/columns.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ListColumn {
6 Num,
7 /// The issue's canonical 8-character id (the identity `crate::id` mints, not
8 /// the colliding-per-checkout `number`). A default column: the compact listing
9 /// is `number`, `id`, `title`, so a reader can copy the token that is stable
10 /// across clones and branches (#1711).
11 Id,
12 Title,
13 Status,
14 Priority,
15 Cycle,
16 Milestone,
17 Reviewer,
18 Start,
19 Due,
20 Overdue,
21 /// The service-derived blocked reason. Empty when the issue is not blocked.
22 Blocked,
23 /// The issue's relationships as `kind -> #target`, in the service's order.
24 Relations,
25 /// The child count, and the rollup for a parent, from the hierarchy service.
26 Progress,
27 /// The issue's project associations with each project's progress and health,
28 /// from the project service (WP04 T018, FR-003). Requested by `--show progress`
29 /// (or by the `projects` column name), never emitted by default.
30 Projects,
31 /// The issue's epic references with each epic's progress, weight-aware figure
32 /// and blocked count, from the epic service (WP04 T018, FR-003). Requested by
33 /// `--show progress` (or by the `epics` column name), never emitted by default.
34 Epics,
35 /// The issue's resolved category label, from the service (WP04 T019).
36 Category,
37 /// The issue's **column** usage against its WIP limit, from the shared snapshot
38 /// (mission `configurable-wip-limits-01M3C6WX`, WP02). The cell is the column's
39 /// count/limit, repeated on each of its rows — a per-row copy is what a table
40 /// column is, exactly as `Category` repeats a per-column fact.
41 Wip,
42 /// Whether the issue is archived (issue #1492). Derived from the filename,
43 /// never stored; appended to the default columns only when at least one row
44 /// is archived.
45 Archived,
46 /// The issue's health: its own value, with the derived rollup beside it when
47 /// its children say something different (`at_risk→unknown`). Read from the one
48 /// portable health document (mission `project-issue-health-status-01M3C8HX`,
49 /// WP05 T028), so the CLI, the TUI and MCP cannot disagree about a state.
50 Health,
51}
52
53/// The relationship facts the `--show relationships` columns render, computed
54/// once per issue from the service.
55///
56/// Both fields are precomputed strings rather than something `cell` derives,
57/// because deriving blocked state or direction here is exactly the boundary
58/// breach `contracts/surface-parity.md` §3 forbids.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct RelationshipCells {
61 /// The blocked reason, empty when the issue is not blocked.
62 pub blocked: String,
63 /// `kind -> #target` for every relationship, in the service's order.
64 pub relationships: String,
65}
66
67impl ListColumn {
68 pub(super) fn parse(value: &str) -> Result<Self> {
69 match value.to_ascii_lowercase().as_str() {
70 "num" => Ok(Self::Num),
71 // `hash` is the word the issue that asked for this column used; `id` is
72 // the field's own name in the model. Both spellings reach the same
73 // column, so neither has to be guessed at.
74 "id" | "hash" => Ok(Self::Id),
75 "title" => Ok(Self::Title),
76 "status" => Ok(Self::Status),
77 "priority" => Ok(Self::Priority),
78 "cycle" => Ok(Self::Cycle),
79 "milestone" => Ok(Self::Milestone),
80 "reviewer" => Ok(Self::Reviewer),
81 "start" => Ok(Self::Start),
82 "due" => Ok(Self::Due),
83 "overdue" => Ok(Self::Overdue),
84 "blocked" => Ok(Self::Blocked),
85 "relations" | "relationships" => Ok(Self::Relations),
86 "progress" | "children" => Ok(Self::Progress),
87 // WP04 T018: the project/health column, per `contracts/cli-surface.md` §3.
88 "projects" | "project" => Ok(Self::Projects),
89 // WP04 T018: the epic/progress column, per `contracts/cli-surface.md` §3.
90 "epics" | "epic" => Ok(Self::Epics),
91 // WP04 T019: `--show category`, per `contracts/cli-surface.md` §3.
92 "category" => Ok(Self::Category),
93 "archived" => Ok(Self::Archived),
94 "health" => Ok(Self::Health),
95 // WP02 of `configurable-wip-limits-01M3C6WX`.
96 "wip" => Ok(Self::Wip),
97 _ => Err(TixError::InvalidInput(format!(
98 "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"
99 ))),
100 }
101 }
102
103 pub(super) fn header(self) -> &'static str {
104 match self {
105 Self::Num => "NUM",
106 Self::Id => "ID",
107 Self::Title => "TITLE",
108 Self::Status => "STATUS",
109 Self::Priority => "PRIORITY",
110 Self::Cycle => "CYCLE",
111 Self::Milestone => "MILESTONE",
112 Self::Reviewer => "REVIEWER",
113 Self::Start => "START",
114 Self::Due => "DUE",
115 Self::Overdue => "OVERDUE",
116 Self::Blocked => "BLOCKED",
117 Self::Relations => "RELATIONS",
118 Self::Progress => "CHILDREN",
119 Self::Projects => "PROJECTS",
120 Self::Epics => "EPICS",
121 // The header names the *concept*; the cell carries the service's own
122 // `label()`, so the CLI and the TUI cannot spell it differently.
123 Self::Category => "CATEGORY",
124 Self::Archived => "ARCHIVED",
125 Self::Wip => "WIP",
126 Self::Health => "HEALTH",
127 }
128 }
129
130 pub(super) fn cap(self) -> usize {
131 match self {
132 Self::Num => 6,
133 // The canonical id is exactly 8 Crockford base32 characters, so the cap
134 // is its width and the column never eats the title's room (#1711).
135 Self::Id => 8,
136 Self::Title => 40,
137 Self::Status => 16,
138 Self::Priority => 12,
139 Self::Cycle | Self::Milestone | Self::Reviewer => 20,
140 Self::Start | Self::Due => 10,
141 Self::Overdue => 7,
142 Self::Blocked | Self::Relations => 40,
143 // `3 1/3` — the count and the rollup, side by side.
144 Self::Progress => 16,
145 // `Platform hardening (p-3f9a2c) 1/4 done, overdue 1, blocked 0, unassigned 2`
146 // — one association, so the cell is the visible unit of the column.
147 Self::Projects => 60,
148 // `Sync hardening (e-3f9a2c) 1/4 done, weighted 3/5 (60%), blocked 1`
149 // — one reference, so the cell is the visible unit of the column.
150 Self::Epics => 60,
151 // The longest current label is "Not started" (12); 14 leaves a little
152 // room so a future label does not silently truncate into a lie.
153 Self::Category => 14,
154 // "ARCHIVED" is the whole cell value, so the cap is its width.
155 Self::Archived => 8,
156 // `4/5 12/20d!` — the item pair, the estimate pair and the marker.
157 Self::Wip => 20,
158 // `needs_attention→at_risk` — the two layers, when they disagree.
159 Self::Health => 22,
160 }
161 }
162}
163
164pub fn parse_columns(value: &str) -> Result<Vec<ListColumn>> {
165 if value.trim().is_empty() {
166 return Err(TixError::InvalidInput(
167 "list columns must not be empty".to_string(),
168 ));
169 }
170 let mut columns = Vec::new();
171 for value in value.split(',') {
172 let column = ListColumn::parse(value.trim())?;
173 if columns.contains(&column) {
174 return Err(TixError::InvalidInput(format!(
175 "duplicate list column `{}`",
176 value.trim()
177 )));
178 }
179 columns.push(column);
180 }
181 Ok(columns)
182}
183
184/// The default column set: `NUM`, `ID`, `TITLE` — one identity pair and the
185/// title, nothing else (#1711).
186///
187/// It used to grow to ten columns as soon as any listed issue carried a cycle,
188/// milestone, reviewer or date, which meant the listing's *shape* depended on the
189/// data rather than on the request: the same command printed four columns on
190/// Monday and ten on Tuesday. The full board is `tix render`, and every column
191/// this set dropped is one `--columns` away.
192///
193/// The archived marker stays conditional for the reason it always was: it is
194/// noise on a board that has opted into `--with-archived` but archived nothing.
195pub(super) fn default_columns(issues: &[Issue]) -> Vec<ListColumn> {
196 let mut columns = vec![ListColumn::Num, ListColumn::Id, ListColumn::Title];
197 if issues.iter().any(|issue| issue.archived) {
198 columns.push(ListColumn::Archived);
199 }
200 columns
201}
202
203/// The columns `--show relationships` appends, in order.
204///
205/// Appended rather than interleaved so an existing `--columns` list keeps its
206/// meaning, and so the two relationship facts land together where a reader
207/// comparing two issues sees them side by side.
208pub(super) const RELATIONSHIP_COLUMNS: [ListColumn; 2] =
209 [ListColumn::Blocked, ListColumn::Relations];
210
211/// Append the columns a `--show` value asks for, in the established order.
212///
213/// Pure, and separate from `run`, so the column-set shape is pinned by a unit test
214/// rather than by a TTY-bound whole-`run` test. `progress` appends the
215/// [`ListColumn::Progress`] (`CHILDREN`) column **and** the
216/// [`ListColumn::Projects`] column after it (WP04 T018), while naming `progress` in
217/// `--columns` requests the `CHILDREN` data alone.
218///
219/// A column already requested by name is not duplicated, which is why each push
220/// checks membership first.
221pub(super) fn append_show_columns(columns: &mut Vec<ListColumn>, shows: &[&str]) {
222 let mut push = |column: ListColumn| {
223 if !columns.contains(&column) {
224 columns.push(column);
225 }
226 };
227 if shows.contains(&"relationships") {
228 for column in RELATIONSHIP_COLUMNS {
229 push(column);
230 }
231 }
232 if shows.contains(&"progress") {
233 push(ListColumn::Progress);
234 push(ListColumn::Projects);
235 // WP04 T018: `--show progress` also carries the epic progress/weight column
236 // (`contracts/cli-surface.md` §3), after PROJECTS so both pre-existing columns
237 // keep their meaning and position.
238 push(ListColumn::Epics);
239 }
240 if shows.contains(&"category") {
241 push(ListColumn::Category);
242 }
243 if shows.contains(&"wip") {
244 push(ListColumn::Wip);
245 }
246 if shows.contains(&"health") {
247 push(ListColumn::Health);
248 }
249}
250
251/// The `--show` values `tix list` understands.
252///
253/// Returns them **deduplicated and in the order given**, so `--show progress
254/// relationships` and `--show relationships,progress` are the same request.
255pub(super) fn wants_show(show: Option<&str>) -> Result<Vec<&'static str>> {
256 let mut out: Vec<&'static str> = Vec::new();
257 for token in show.unwrap_or("").split(',').map(str::trim) {
258 if token.is_empty() {
259 continue;
260 }
261 let known = match token {
262 "relationships" => "relationships",
263 "progress" => "progress",
264 // WP04 T019, per `contracts/cli-surface.md` §3.
265 "category" => "category",
266 // WP02 of `configurable-wip-limits-01M3C6WX`.
267 "wip" => "wip",
268 // WP05 T028 of `project-issue-health-status-01M3C8HX`.
269 "health" => "health",
270 other => {
271 return Err(TixError::InvalidInput(format!(
272 "unknown --show value `{other}`; this surface understands \
273 `relationships`, `progress`, `category`, `wip` and `health`"
274 )));
275 }
276 };
277 if !out.contains(&known) {
278 out.push(known);
279 }
280 }
281 Ok(out)
282}
283
284pub fn render_table(
285 issues: &[Issue],
286 columns: &[ListColumn],
287 group: Option<&str>,
288 resolution: &crate::workflow::CategoryResolution,
289 width: usize,
290) -> String {
291 render_table_with(
292 issues, columns, group, resolution, width, None, None, None, None, None, None, None, None,
293 )
294}
295
296/// [`render_table`], with the service-derived relationship facts the
297/// `--show relationships` columns render.
298///
299/// `derived` is keyed by issue number and only consulted for
300/// [`ListColumn::Blocked`] and [`ListColumn::Relations`]; passing `None` makes
301/// this identical to [`render_table`], which is what keeps the default listing
302/// and MCP's `render_default_table` unchanged.
303/// `hierarchy` carries the [`ListColumn::Progress`] cell, keyed by issue number,
304/// and is consulted only for that column. It is a **separate** map from `derived`
305/// rather than a new field on [`RelationshipCells`], because a child count is not a
306/// relationship fact and putting it there would make the type's name lie about its
307/// contents — the same mistake a second rollup implementation would be.
308// The three derived facts (relationships, hierarchy, WIP) are separate optional maps
309// rather than one struct because each is built by a different service and requested
310// by a different flag; `run` above carries the same allow for the same reason.
311#[allow(clippy::too_many_arguments)]
312pub fn render_table_with(
313 issues: &[Issue],
314 columns: &[ListColumn],
315 group: Option<&str>,
316 resolution: &crate::workflow::CategoryResolution,
317 width: usize,
318 derived: Option<&BTreeMap<u32, RelationshipCells>>,
319 hierarchy: Option<&BTreeMap<u32, String>>,
320 wip: Option<&crate::wip::WipSnapshot>,
321 health: Option<&crate::health::PortableHealth>,
322 projects: Option<&BTreeMap<u32, String>>,
323 epics: Option<&BTreeMap<u32, String>>,
324 project_groups: Option<&BTreeMap<u32, String>>,
325 epic_groups: Option<&BTreeMap<u32, String>>,
326) -> String {
327 let (header, rows) = table_rows(
328 issues, columns, resolution, derived, hierarchy, wip, health, projects, epics,
329 );
330 render_table_from(
331 issues,
332 columns,
333 group,
334 resolution,
335 width,
336 &header,
337 &rows,
338 project_groups,
339 epic_groups,
340 false,
341 )
342}
343
344/// The table's header row and one cell row per issue, in issue order.
345///
346/// Split out of [`render_table_with`] so `--format` serializes **exactly the cells
347/// the table would have shown** rather than a second reading of the same listing:
348/// one place builds the cells, so a structured record and a table row cannot
349/// disagree about what a column holds (#1711).
350#[allow(clippy::too_many_arguments)]
351pub(super) fn table_rows(
352 issues: &[Issue],
353 columns: &[ListColumn],
354 resolution: &crate::workflow::CategoryResolution,
355 derived: Option<&BTreeMap<u32, RelationshipCells>>,
356 hierarchy: Option<&BTreeMap<u32, String>>,
357 wip: Option<&crate::wip::WipSnapshot>,
358 health: Option<&crate::health::PortableHealth>,
359 projects: Option<&BTreeMap<u32, String>>,
360 epics: Option<&BTreeMap<u32, String>>,
361) -> (Vec<String>, Vec<Vec<String>>) {
362 let today = crate::model::today();
363 let rows: Vec<Vec<String>> = issues
364 .iter()
365 .map(|issue| {
366 let cells = derived.and_then(|map| map.get(&issue.number));
367 columns
368 .iter()
369 .map(|column| {
370 cell(
371 *column, issue, resolution, &today, cells, hierarchy, wip, health,
372 projects, epics,
373 )
374 })
375 .collect()
376 })
377 .collect();
378 let header = columns
379 .iter()
380 .map(|column| column.header().to_string())
381 .collect();
382 (header, rows)
383}
384
385/// Render [`table_rows`] output: column widths, the group sections, and the
386/// padding that lands every line exactly on `width`.
387///
388/// `truncate` is the **compact** mode `tix list` renders in — one physical line per
389/// issue, a cell that does not fit cropped rather than wrapped (#1711). Wrapping
390/// stays the default for a caller that named eight columns by hand, which is not
391/// the caller who asked for one line per issue.
392#[allow(clippy::too_many_arguments)]
393pub(super) fn render_table_from(
394 issues: &[Issue],
395 columns: &[ListColumn],
396 group: Option<&str>,
397 resolution: &crate::workflow::CategoryResolution,
398 width: usize,
399 header: &[String],
400 rows: &[Vec<String>],
401 project_groups: Option<&BTreeMap<u32, String>>,
402 epic_groups: Option<&BTreeMap<u32, String>>,
403 truncate: bool,
404) -> String {
405 let natural: Vec<usize> = columns
406 .iter()
407 .enumerate()
408 .map(|(index, column)| {
409 // A crop needs a width to crop *to*. With no terminal, the compact
410 // listing prints the whole title on one line rather than cutting it at
411 // the column's cap, so `tix list | grep` keeps every word (#1711).
412 let cap = if truncate && width == 0 && *column == ListColumn::Title {
413 usize::MAX
414 } else {
415 column.cap()
416 };
417 rows.iter()
418 .map(|row| display_width(&row[index]))
419 .chain(std::iter::once(display_width(column.header())))
420 .max()
421 .unwrap_or(1)
422 .min(cap)
423 .max(1)
424 })
425 .collect();
426 let separator_width = columns.len().saturating_sub(1) * 2;
427 let natural_width = separator_width + natural.iter().sum::<usize>();
428 let width = if width == 0 { natural_width } else { width };
429 let available = width.saturating_sub(separator_width).max(columns.len());
430 // The title column takes whatever is left, so every other column leaves it room
431 // for at least one column — but only when there **is** a title to leave room
432 // for. The reservation used to be unconditional, which cost the last column a
433 // character on a table with no title at all (`--columns num,id`), and in the
434 // compact mode that character is a cropped cell rather than padding (#1711).
435 let title = columns
436 .iter()
437 .position(|column| *column == ListColumn::Title);
438 let mut widths = vec![1; columns.len()];
439 let mut remaining = available;
440 for (index, column) in columns.iter().enumerate() {
441 if *column == ListColumn::Title {
442 continue;
443 }
444 let take = natural[index].min(remaining.saturating_sub(usize::from(title.is_some())));
445 widths[index] = take.max(1);
446 remaining = remaining.saturating_sub(widths[index]);
447 }
448 if let Some(title) = title {
449 widths[title] = remaining;
450 }
451 let line_width = width;
452 let mut out = String::new();
453 render_row(&mut out, header, &widths, line_width, truncate);
454 if let Some(group) = group.filter(|group| {
455 *group == "cycle"
456 || *group == "milestone"
457 || (*group == "project" && project_groups.is_some())
458 || (*group == "epic" && epic_groups.is_some())
459 }) {
460 let mut progress: BTreeMap<String, (usize, usize)> = BTreeMap::new();
461 for issue in issues {
462 let key = group_key(issue, group, project_groups, epic_groups);
463 let entry = progress.entry(key).or_insert((0, 0));
464 entry.1 += 1;
465 // WP04 T019: the service's completion decision (C-004), for the same
466 // reason the `Overdue` cell uses it. A group header that counts a
467 // differently-spelled completion column would contradict the board's
468 // own category map in the same table.
469 if crate::workflow::is_completed(issue, resolution) {
470 entry.0 += 1;
471 }
472 }
473 let mut current = String::new();
474 // `rows` is `issues`'s own cell row, index for index (both come from
475 // [`table_rows`]), so walking them together is what keeps a group heading
476 // attached to the rows under it. This used to re-find each row with a
477 // pointer-equality scan, which is quadratic on a board of a thousand
478 // issues and said nothing a zip does not.
479 for (issue, row) in issues.iter().zip(rows) {
480 let key = group_key(issue, group, project_groups, epic_groups);
481 if current != key {
482 current = key.clone();
483 let (done, total) = progress.get(&key).copied().unwrap_or((0, 0));
484 let pct = (done * 100).checked_div(total).unwrap_or(0);
485 out.push_str(&format!(
486 "-- {group}: {key} ({done}/{total} done, {pct}%) --\n"
487 ));
488 }
489 render_row(&mut out, row, &widths, line_width, truncate);
490 }
491 } else {
492 for row in rows {
493 render_row(&mut out, row, &widths, line_width, truncate);
494 }
495 }
496 out
497}
498
499pub(super) fn render_row(
500 out: &mut String,
501 cells: &[String],
502 widths: &[usize],
503 line_width: usize,
504 truncate: bool,
505) {
506 if truncate {
507 // The compact mode: every cell cut to its column, so the row is exactly one
508 // physical line and the issue below it starts on the next one (#1711).
509 let values: Vec<String> = cells
510 .iter()
511 .zip(widths)
512 .map(|(cell, width)| truncate_cell(cell, *width))
513 .collect();
514 for (index, value) in values.iter().enumerate() {
515 if index > 0 {
516 out.push_str(" ");
517 }
518 out.push_str(value);
519 out.push_str(&" ".repeat(widths[index].saturating_sub(display_width(value))));
520 }
521 let padding = line_width
522 .saturating_sub(separator_width(values.len()))
523 .saturating_sub(widths.iter().sum::<usize>());
524 out.push_str(&" ".repeat(padding));
525 out.push('\n');
526 return;
527 }
528 let wrapped: Vec<Vec<String>> = cells
529 .iter()
530 .zip(widths)
531 .map(|(cell, width)| wrap_cell(cell, *width))
532 .collect();
533 let height = wrapped.iter().map(Vec::len).max().unwrap_or(1);
534 for row in 0..height {
535 for (index, cell) in wrapped.iter().enumerate() {
536 if index > 0 {
537 out.push_str(" ");
538 }
539 let value = cell.get(row).map(String::as_str).unwrap_or("");
540 out.push_str(value);
541 out.push_str(&" ".repeat(widths[index].saturating_sub(display_width(value))));
542 }
543 let padding = line_width
544 .saturating_sub(separator_width(wrapped.len()))
545 .saturating_sub(widths.iter().sum::<usize>());
546 out.push_str(&" ".repeat(padding));
547 out.push('\n');
548 }
549}
550
551pub(super) fn separator_width(count: usize) -> usize {
552 count.saturating_sub(1) * 2
553}
554
555/// [`wrap_cell`]'s single-line twin: the value cut to `width`, with an ellipsis
556/// when anything was dropped.
557///
558/// The ellipsis is the point. A silently cut title reads as the issue's real title,
559/// and the truncation marker is the only thing that says otherwise without spending
560/// a second line on it.
561pub(super) fn truncate_cell(value: &str, width: usize) -> String {
562 if width == 0 {
563 return String::new();
564 }
565 if display_width(value) <= width {
566 return value.to_string();
567 }
568 let mut out = String::new();
569 let mut used = 0usize;
570 for character in value.chars() {
571 let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
572 // One column is held back for the ellipsis, so the result fits `width`
573 // exactly rather than `width + 1`.
574 if used + character_width + 1 > width {
575 break;
576 }
577 out.push(character);
578 used += character_width;
579 }
580 // A cut that landed just after a space would otherwise print `word …`.
581 let trimmed = out.trim_end().len();
582 out.truncate(trimmed);
583 out.push('…');
584 out
585}
586
587/// One table cell.
588///
589/// `derived` is `Some` only when the caller asked for relationship
590/// information; the two relationship columns render a `-` placeholder without
591/// it, so the default table is byte-identical to what it was before WP03.
592#[allow(clippy::too_many_arguments)]
593pub(super) fn cell(
594 column: ListColumn,
595 issue: &Issue,
596 resolution: &crate::workflow::CategoryResolution,
597 today: &str,
598 derived: Option<&RelationshipCells>,
599 hierarchy: Option<&BTreeMap<u32, String>>,
600 wip: Option<&crate::wip::WipSnapshot>,
601 health: Option<&crate::health::PortableHealth>,
602 projects: Option<&BTreeMap<u32, String>>,
603 epics: Option<&BTreeMap<u32, String>>,
604) -> String {
605 match column {
606 ListColumn::Num => issue.number.to_string(),
607 // `-` for an issue written before the id schema (the field is optional so
608 // those files keep parsing), never a blank cell: beside other blank cells it
609 // would read as a rendering gap rather than as "this issue has no id yet".
610 ListColumn::Id => issue.id.clone().unwrap_or_else(|| "-".to_string()),
611 ListColumn::Title => issue.title.clone(),
612 ListColumn::Status => issue.status.display().to_string(),
613 ListColumn::Priority => issue.priority.to_string(),
614 ListColumn::Cycle => issue
615 .cycles
616 .as_ref()
617 .map(|v| v.join(","))
618 .unwrap_or_default(),
619 ListColumn::Milestone => issue
620 .milestones
621 .as_ref()
622 .map(|v| v.join(","))
623 .unwrap_or_default(),
624 ListColumn::Reviewer => issue.reviewer.clone().unwrap_or_default(),
625 ListColumn::Start => issue.start_date.clone().unwrap_or_default(),
626 ListColumn::Due => issue.due_date.clone().unwrap_or_default(),
627 ListColumn::Overdue => {
628 // WP04 T019: `is_completed`, not a column comparison (C-004). An issue
629 // that is past due but complete is not overdue, and that is a category
630 // decision — a board mapping its last column to `completed` must not
631 // depend on that column being spelled `done`.
632 if issue.due_date.as_deref().is_some_and(|due| due < today)
633 && !crate::workflow::is_completed(issue, resolution)
634 {
635 "yes".to_string()
636 } else {
637 String::new()
638 }
639 }
640 // WP04 T019: the category label, from the service's own `label()` so the CLI
641 // and the TUI cannot spell the same category differently (WP08 asserts the
642 // detail view agrees with this column).
643 //
644 // An issue in a column the board does not configure resolves to `None`.
645 // That is a configuration error the user should be able to *see* in the
646 // listing rather than have it silently become the safe default here — the
647 // safe default governs transition policy, and applying it to a display would
648 // report a category the board never assigned. Shown as `(unmapped)`, which
649 // is a statement about the file rather than an error the command refuses to
650 // print (T019 step 4 asks for a label, not a failure).
651 ListColumn::Category => crate::workflow::category_of(issue, resolution)
652 .map(|c| c.label())
653 .unwrap_or("(unmapped)")
654 .to_string(),
655 // A `-` rather than an empty cell, because an empty column next to
656 // other empty columns is indistinguishable from a column that was not
657 // rendered at all.
658 ListColumn::Blocked => match derived {
659 Some(cells) if !cells.blocked.is_empty() => cells.blocked.clone(),
660 _ => "-".to_string(),
661 },
662 ListColumn::Relations => match derived {
663 Some(cells) if !cells.relationships.is_empty() => cells.relationships.clone(),
664 _ => "-".to_string(),
665 },
666 // A `-` for a leaf, not `0`. A leaf genuinely has no children, and a `0`
667 // reads as "measured, and there are none" — which is the same sentence as
668 // what a parent with no completed children would say. `-` says "not a
669 // parent", which is the fact the column is for.
670 //
671 // Looked up per issue rather than guarded on the map being non-empty. A leaf
672 // has **no entry at all** — `hierarchy_cells` skips it — so "the map is not
673 // empty" is true on a board where every issue but one is a leaf, and
674 // `cells[&issue.number]` then panicked. Found by running `tix list --show
675 // progress`.
676 ListColumn::Progress => match hierarchy.and_then(|cells| cells.get(&issue.number)) {
677 Some(text) if !text.is_empty() => text.clone(),
678 _ => "-".to_string(),
679 },
680 // A `-` for an issue in no project, matching the `CHILDREN` placeholder: an
681 // empty cell beside other empty cells reads as a rendering gap rather than
682 // as "this issue is in no project". A reference that no longer resolves is
683 // **not** blanked — `project_cells` renders its recorded id — so the two
684 // states stay distinguishable (T018 step 5).
685 ListColumn::Projects => match projects.and_then(|cells| cells.get(&issue.number)) {
686 Some(text) if !text.is_empty() => text.clone(),
687 _ => "-".to_string(),
688 },
689 // A `-` for an issue in no epic, matching the PROJECTS/CHILDREN placeholder.
690 // The cell text is built by the shared service read (`view::epic_cells`), so a
691 // row can never report a figure that disagrees with `tix show` (C-008). A
692 // reference that no longer resolves renders its recorded id, so the two
693 // empty-looking cases stay distinguishable (T018 step 5).
694 ListColumn::Epics => match epics.and_then(|cells| cells.get(&issue.number)) {
695 Some(text) if !text.is_empty() => text.clone(),
696 _ => "-".to_string(),
697 },
698 // The issue's own value, and the derived rollup beside it when the two
699 // differ: a parent whose children are at risk reads `unknown→at_risk`, so the
700 // cell never hides a derived layer that says something the current one does
701 // not. Both values come from the document, never from a local formula.
702 ListColumn::Health => health
703 .and_then(|document| {
704 document.scope(&crate::health::HealthScope::of_issue(issue.number))
705 })
706 .map(|entry| match entry.rollup.as_ref() {
707 Some(derived) if entry.state != derived.state => {
708 format!("{}→{}", entry.state, derived.state)
709 }
710 _ => entry.state.clone(),
711 })
712 .unwrap_or_else(|| "-".to_string()),
713 ListColumn::Archived => {
714 if issue.archived {
715 "ARCHIVED".to_string()
716 } else {
717 "-".to_string()
718 }
719 }
720 // WP02 of `configurable-wip-limits-01M3C6WX`: the **column's** usage against
721 // its limit, from the one shared snapshot. A column with no limit is `-`
722 // rather than an empty cell, for the reason `Blocked` gives: an empty cell
723 // beside other empty cells reads as a rendering gap, not as "no limit here".
724 //
725 // The snapshot is the one the surface was built from, so a row can never
726 // report figures that disagree with the column's own heading count.
727 ListColumn::Wip => wip
728 .and_then(|snapshot| snapshot.column(issue.status.display()))
729 .filter(|usage| usage.limit.is_some())
730 .map_or_else(|| "-".to_string(), crate::wip::ColumnUsage::cell),
731 }
732}
733
734pub(super) fn wrap_cell(value: &str, width: usize) -> Vec<String> {
735 if value.is_empty() || width == 0 {
736 return vec![String::new()];
737 }
738 let mut lines = Vec::new();
739 let mut line = String::new();
740 for word in value.split_whitespace() {
741 let word_width = display_width(word);
742 let candidate_width = display_width(&line) + usize::from(!line.is_empty()) + word_width;
743 if !line.is_empty() && candidate_width > width {
744 lines.push(std::mem::take(&mut line));
745 }
746 if word_width <= width {
747 if !line.is_empty() {
748 line.push(' ');
749 }
750 line.push_str(word);
751 } else {
752 let mut chunk = String::new();
753 for character in word.chars() {
754 let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
755 if !chunk.is_empty() && display_width(&chunk) + character_width > width {
756 lines.push(std::mem::take(&mut chunk));
757 }
758 chunk.push(character);
759 }
760 line = chunk;
761 }
762 }
763 if !line.is_empty() || lines.is_empty() {
764 lines.push(line);
765 }
766 lines
767}
768
769pub(super) fn display_width(value: &str) -> usize {
770 UnicodeWidthStr::width(value)
771}
772
773/// The structured formats `tix list --format` writes (#1711).
774///
775/// Deliberately without JSON. The listing's JSON spelling is its own piece of work,
776/// and a `--format` that quietly grew a fourth arm would be a machine contract
777/// nobody reviewed.
778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
779pub enum ListFormat {
780 Xml,
781 Yaml,
782 Toml,
783}
784
785impl ListFormat {
786 pub(super) fn parse(value: &str) -> Result<Self> {
787 match value.to_ascii_lowercase().as_str() {
788 "xml" => Ok(Self::Xml),
789 "yml" | "yaml" => Ok(Self::Yaml),
790 "toml" => Ok(Self::Toml),
791 other => Err(TixError::InvalidInput(format!(
792 "unknown list format `{other}`; this surface understands `xml`, `yml` and `toml`"
793 ))),
794 }
795 }
796}
797
798/// Serialize a listing as records: one record per issue, keyed by the **displayed
799/// columns** (#1711).
800///
801/// The input is [`table_rows`]' own output, so `--format` and the table are two
802/// renderings of one listing — `--columns` selects a column once, for both, and a
803/// derived column (`blocked`, `children`, `health`) carries the service's value into
804/// the structured form rather than a `-`.
805///
806/// Group sections are a plain-text affordance and are **not** part of a record, so
807/// `--format` renders a flat sequence however `--group` was spelled.
808pub(super) fn render_records(
809 header: &[String],
810 rows: &[Vec<String>],
811 format: ListFormat,
812) -> Result<String> {
813 let records: Vec<IndexMap<String, String>> = rows
814 .iter()
815 .map(|row| {
816 header
817 .iter()
818 .zip(row)
819 .map(|(column, value)| (column.to_ascii_lowercase(), value.clone()))
820 .collect()
821 })
822 .collect();
823 // `issues` is the document's one root key, so the three formats agree about the
824 // shape a consumer parses: a list of records under one name.
825 let document = IndexMap::from([("issues".to_string(), records)]);
826 match format {
827 ListFormat::Xml => Ok(render_xml(&document["issues"])),
828 ListFormat::Yaml => serde_yaml::to_string(&document)
829 .map_err(|error| TixError::InvalidInput(format!("could not render yaml: {error}"))),
830 ListFormat::Toml => toml::to_string(&document)
831 .map_err(|error| TixError::InvalidInput(format!("could not render toml: {error}"))),
832 }
833}
834
835/// The XML spelling: `<issues><issue><column>value</column>…</issue></issues>`.
836///
837/// Hand-written because the document is flat and its keys are the column headers —
838/// bringing in a serializer for five element kinds would buy nothing but a
839/// dependency. Values are escaped, so a title containing `<` cannot produce a
840/// document that no parser will read.
841pub(super) fn render_xml(records: &[IndexMap<String, String>]) -> String {
842 let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<issues>\n");
843 for record in records {
844 out.push_str(" <issue>\n");
845 for (column, value) in record {
846 out.push_str(" <");
847 out.push_str(column);
848 out.push('>');
849 out.push_str(&escape_xml(value));
850 out.push_str("</");
851 out.push_str(column);
852 out.push_str(">\n");
853 }
854 out.push_str(" </issue>\n");
855 }
856 out.push_str("</issues>\n");
857 out
858}
859
860pub(super) fn escape_xml(value: &str) -> String {
861 let mut out = String::with_capacity(value.len());
862 for character in value.chars() {
863 match character {
864 '&' => out.push_str("&"),
865 '<' => out.push_str("<"),
866 '>' => out.push_str(">"),
867 '"' => out.push_str("""),
868 '\'' => out.push_str("'"),
869 _ => out.push(character),
870 }
871 }
872 out
873}
874
875/// WP04: takes the service's [`CategoryResolution`](crate::workflow::CategoryResolution)
876/// rather than a completion column name.
877///
878/// MCP's `list_issues` called this with `done_column`, which is where the
879/// "100% of surfaces agree" claim would have quietly stopped: the CLI would render
880/// `Overdue` and the group rollup through the service while MCP rendered them
881/// through a string. One parameter, both surfaces.
882///
883/// It renders in the CLI's **compact** mode, for the same reason: `list_issues`
884/// carries `tix list`'s text, so it has to be the same text — including the crop
885/// rule, which is a no-op at the zero width a caller passes when there is no
886/// terminal to crop to (#1711).
887pub fn render_default_table(
888 issues: &[Issue],
889 group: Option<&str>,
890 resolution: &crate::workflow::CategoryResolution,
891 width: usize,
892) -> String {
893 let columns = default_columns(issues);
894 let (header, rows) = table_rows(
895 issues, &columns, resolution, None, None, None, None, None, None,
896 );
897 render_table_from(
898 issues, &columns, group, resolution, width, &header, &rows, None, None, true,
899 )
900}