Skip to main content

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("&amp;"),
865            '<' => out.push_str("&lt;"),
866            '>' => out.push_str("&gt;"),
867            '"' => out.push_str("&quot;"),
868            '\'' => out.push_str("&apos;"),
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}