1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::search::control_token;
use crate::storage::Store;
use indexmap::IndexMap;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
mod columns;
mod group;
mod scope;
pub use columns::*;
#[allow(clippy::wildcard_imports)]
use group::*;
pub use scope::*;
const FALLBACK_WIDTH: usize = 0;
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
pub fn run(
store: &Store,
status: Option<&str>,
filter: Option<&str>,
project: Option<&str>,
epic: Option<&str>,
sort: Option<&str>,
group: Option<&str>,
columns: Option<&str>,
show: Option<&str>,
with_archived: bool,
all: bool,
format: Option<&str>,
ready: Option<ReadyOptions>,
) -> Result<()> {
// `list` understands two `--show` values, not one. `link::wants_relationships`
// rejects anything that is not `relationships`, so a shared validator that
// rejected `progress` would make the documented flag (cli-surface.md §2)
// unusable. Validated here, before anything is printed, for the same stdout
// reason `view` validates early.
// One parse, one call: this used to call `wants_show` twice, and while the two
// answers cannot disagree today, a validator that ran twice is a validator whose
// cost and behaviour scale with the number of callers rather than with the input.
let shows = wants_show(show)?;
let show_relationships = shows.contains(&"relationships");
let show_progress = shows.contains(&"progress");
// The format is validated before anything is read, so a typo cannot print half
// a listing and then fail (`--format json` is a refusal, not a table).
let format = format.map(ListFormat::parse).transpose()?;
// The default scope (#1711): my issues in the board's entry column, applied
// only when this request names no selection of its own. Resolved through the one
// seam MCP shares, so the two surfaces cannot disagree about what "no arguments"
// means.
let mut issues = scoped_query(
store,
&ListSelection {
status,
filter,
project,
epic,
all,
},
with_archived,
)?;
// The service's completion decision, read once and reused by the ready filter,
// the group rollup and the `Overdue` cell (WP04 T019, C-004). It is computed
// here because `tix ready` needs it before the ordering, not because the
// listing changed its mind about who decides completion.
let (_, resolution) = crate::workflow::active_resolution(&store.config);
if let Some(options) = ready {
// "Unblocked" comes from the one graph the `blocked` column reads, so this
// list cannot call an issue ready while `tix list --show relationships`
// calls it blocked. An issue with no graph entry is unblocked: nothing
// points at it.
let cells = relationship_cells(store)?;
issues.retain(|issue| {
cells
.get(&issue.number)
.is_none_or(|entry| entry.blocked.is_empty())
});
// Startable work: not under way, not finished, and not canceled — see
// [`is_startable`] for why the board's categories *and* the board's order
// both have a say. The inbox is deliberately **included**: it is before the
// entry column, and a freshly filed issue nothing blocks is work that can be
// picked up, which is the same answer `bd ready` gives.
let board = &store.config.default_config().columns;
let entry = entry_column(&store.config);
issues.retain(|issue| is_startable(issue, &resolution, board, entry.as_deref()));
sort_ready(&mut issues);
// Capped **after** ordering, so `--limit 1` is the single next issue rather
// than the first one in file order.
if let Some(limit) = options.limit {
issues.truncate(limit);
}
}
// `--project <reference>` is applied with the **same** membership predicate
// `tix search "project:<id>"` uses ([`crate::search::StructuredFilter::project_matches`]),
// but the reference is resolved to its stable id **here** rather than spliced
// into the `--filter` string. An id never contains whitespace, so resolution
// keeps a multi-word display name (`--project "Platform hardening"`) from being
// split into `project:Platform` plus a stray haystack term — which silently
// returned an empty listing with no diagnostic. An unknown reference is kept
// verbatim and matches nothing, exactly as the criterion documents (WP09 item D).
if let Some(project) = project {
let reference = crate::projects::resolve_project_id(&store.config, project)
.map_or_else(|_| project.to_string(), |id| id.to_string());
let criterion = crate::search::StructuredFilter {
project: Some(reference),
..crate::search::StructuredFilter::default()
};
issues.retain(|issue| criterion.project_matches(issue));
}
// `--epic <reference>` is the epic twin of `--project`: the **same** membership
// predicate `tix search "epic:<id>"` uses ([`crate::search::StructuredFilter::epic_matches`]),
// resolved to its stable id here so a multi-word display name is not split into
// `epic:Platform` plus a stray haystack term (which silently returned an empty
// listing). An unknown reference is kept verbatim and matches nothing, so a typo
// yields a visible empty listing rather than a silent unfiltered one (T029 step
// 6, WP09 item C).
if let Some(epic) = epic {
let reference = crate::epics::dereference_needle(&store.config.epics, epic);
let criterion = crate::search::StructuredFilter {
epic: Some(reference),
..crate::search::StructuredFilter::default()
};
issues.retain(|issue| criterion.epic_matches(issue));
}
let sort_key = sort
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "sort")));
let group_key = group
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "group")));
// Project grouping and sorting need each issue's resolved memberships, read once
// from the shared index and labelled by the service, so the CLI cannot bucket a
// project differently from the board (WP09 item D).
let by_project =
sort_key.as_deref() == Some("project") || group_key.as_deref() == Some("project");
let project_groups = if by_project {
Some(project_group_labels(store, &issues))
} else {
None
};
// Epic grouping and sorting are the same shape: one shared inverse-membership
// read from the epic service, labelled by the service's own
// [`crate::search::epic_bucket_label`], so `tix list --group epic` and the
// board's `EpicPartition` cannot spell a bucket two ways (WP09 item C).
let by_epic = sort_key.as_deref() == Some("epic") || group_key.as_deref() == Some("epic");
let epic_groups = if by_epic {
Some(epic_group_labels(store, &issues))
} else {
None
};
if by_project {
// Grouping must see a project's rows contiguously, so a project partition is
// applied before (and instead of) a secondary sort when the caller asked to
// group or sort by project.
sort_by_project(store, &mut issues, project_groups.as_ref().unwrap());
} else if by_epic {
// The epic partition ranks by the same declaration order the board uses.
sort_by_epic(store, &mut issues, epic_groups.as_ref().unwrap());
} else if ready.is_none() {
// `tix ready` ordered the listing already, and that order *is* the command:
// sorting again here would put the issues back in file order and make the
// priority the list was built around disappear.
sort_issues(&mut issues, sort_key.as_deref());
}
// WP04 T019 (C-004): completion comes from the service, not from a column name
// resolved in the command with a literal `"done"` fallback.
//
// The `unwrap_or("done")` was the defect, and it was a **behaviour** defect
// rather than a tidiness one. A board that configures no `done_column` fell
// back to the literal string, so `tix list --show progress` counted an issue in
// a column actually named `done` as complete on a board that had declared no
// completion column at all — while `tix mv` into that same column, going
// through the seam, recorded no decision as reaching completion. Two surfaces
// answering "is this done?" differently is precisely what this mission exists
// to end.
// Still needed, and still a *column name*: the relationship graph and the
// hierarchy rollup are existing services that take the board's completion
// column as a parameter. Re-deriving it here is what they already did; changing
// their signatures is outside this WP's map and is recorded rather than done
// half-way.
// WP09: the **service's** completion column. The column is only a *parameter*
// for `hierarchy_cells` and the `RelationshipGraph` — both are existing services
// that take a completion column name, and changing their signatures is outside
// this mission. But supplying that name from `done_column` meant the blocked
// marker and the progress rollup were computed against the wrong column on a
// board whose `category_map` puts `completed` elsewhere. The meaning is now
// resolved once, here, from the service.
let done_column =
crate::workflow::completion_column_or_default(store.config.default_config(), &store.config);
let mut columns = columns.map(parse_columns).transpose()?.unwrap_or_else(|| {
if ready.is_some() {
ready_columns(&issues)
} else {
default_columns(&issues)
}
});
// The `--show`-driven columns are appended in one pure step, so the column-set
// shape (`--show progress` => CHILDREN then PROJECTS, while `--columns progress`
// is CHILDREN alone) is pinned by a unit test rather than by a TTY-bound
// whole-`run` test.
append_show_columns(&mut columns, &shows);
let mut derived: Option<BTreeMap<u32, RelationshipCells>> = None;
// Populated when the caller asked for a relationship column **or** passed
// `--show relationships`.
//
// It used to be gated on `show_relationships` alone, which meant
// `tix list --columns num,title,status,blocked` accepted `blocked`, built a
// table with the column in it, and then rendered `-` for every row — because
// `derived` was `None` and `render_table_with` falls through to the
// empty-cell placeholder. A column that is accepted and always empty reads as
// "nothing is blocked" rather than "ask for the detail", which is a worse lie
// than not offering the column.
//
// Asking for `blocked` or `relations` by name is a request for the data, not
// only for the heading.
let wants_relationship_column = RELATIONSHIP_COLUMNS.iter().any(|c| columns.contains(c));
if show_relationships || wants_relationship_column {
// `--show relationships` already appended them in `append_show_columns`;
// naming one by hand (`--columns blocked`) appends its sibling here, so
// the two relationship facts always travel together.
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;
// Asking for `progress` **by name** is a request for the data, not only for the
// heading — the same rule the relationship columns follow, for the same reason:
// a column that is accepted and always `-` reads as "nothing is a parent" rather
// than "ask for the detail".
if wants_progress_data(show_progress, &columns) {
hierarchy = Some(hierarchy_cells(store, &done_column)?);
}
// WP04 T018 (`contracts/cli-surface.md` §3): `--show progress` also carries the
// project progress/health column (appended by `append_show_columns`, after
// `CHILDREN`, so the pre-existing column keeps its meaning and position).
let mut projects: Option<BTreeMap<u32, String>> = None;
if columns.contains(&ListColumn::Projects) {
projects = Some(project_cells(store, &issues)?);
}
// WP04 T018 (`contracts/cli-surface.md` §3): `--show progress` also carries the
// epic progress/weight column, from the same one-pass service read `tix show`
// uses for one issue.
let mut epics: Option<BTreeMap<u32, String>> = None;
if columns.contains(&ListColumn::Epics) {
epics = Some(crate::commands::view::epic_cells(store, &issues)?);
}
// WP02 T010: `--show wip` adds the column, and building the snapshot is what
// makes it show data rather than a column of `-` (the rule `blocked`,
// `relations` and `progress` already follow).
let mut wip: Option<crate::wip::WipSnapshot> = None;
if columns.contains(&ListColumn::Wip) {
wip = Some(crate::wip::WipSnapshot::build(
store.config.default_config(),
&issues,
));
}
// The health document, built only when the column (or `--show health`) asks for
// it: asking for the column by name is a request for the data, the rule the
// relationship, hierarchy and WIP columns already follow.
let mut health: Option<crate::health::PortableHealth> = None;
if columns.contains(&ListColumn::Health) {
health = Some(crate::commands::export::run_health_core(&store.config)?);
}
let width = terminal_width();
let (header, rows) = table_rows(
&issues,
&columns,
&resolution,
derived.as_ref(),
hierarchy.as_ref(),
wip.as_ref(),
health.as_ref(),
projects.as_ref(),
epics.as_ref(),
);
let text = match format {
// A structured format carries the displayed columns, one record per issue,
// in the order they were asked for — so `--columns` and `--format` are one
// feature rather than two (#1711).
Some(format) => render_records(&header, &rows, format)?,
// `true`: the compact listing is one physical line per issue, with the title
// cropped to the terminal rather than wrapped onto the next line (#1711).
None => render_table_from(
&issues,
&columns,
group_key.as_deref(),
&resolution,
width,
&header,
&rows,
project_groups.as_ref(),
epic_groups.as_ref(),
true,
),
};
print!("{text}");
Ok(())
}
/// Whether the caller asked for the progress **data**: `--show progress`, or a
/// `progress` column named in `--columns`.
///
/// Asking for the column by name is a request for the data, not only for the
/// heading — the rule the relationship columns already follow. Without it
/// `--columns progress` was accepted, built the column, and rendered `-` for
/// every row, which reads as "nothing is a parent" rather than "ask for the
/// detail". Extracted so the branch has a unit test of its own (#1380), since a
/// whole-`run` test needs a TTY-bound stdout.
fn wants_progress_data(show_progress: bool, columns: &[ListColumn]) -> bool {
show_progress || columns.contains(&ListColumn::Progress)
}
/// Every issue's hierarchy cell, read from the service in one pass.
///
/// The cell text is produced by the **same** [`crate::commands::parent::HierarchyFacts`]
/// that `tix parent show` and the `tix show` block use, so the three surfaces
/// cannot report different figures for one issue (parity contract §2). T017 step 3
/// forbids computing a progress number here, and this is how that is honoured: this
/// function reads and formats, and computes nothing.
///
/// Empty for a leaf, which the cell renders as `-`.
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)
}
/// Every issue's project/health cell, read from the service in one pass (WP04
/// T018, FR-003).
///
/// The text is built from the service's typed values — one health read per project
/// for the whole listing — so a row can never report a figure that disagrees with
/// `tix show` for the same snapshot (C-006). An issue in no project has no entry
/// (rendered `-`), and a reference that no longer resolves renders its recorded id
/// and `(missing reference)`, so the two empty-looking cases stay distinguishable.
pub(crate) fn project_cells(store: &Store, issues: &[Issue]) -> Result<BTreeMap<u32, String>> {
// The figures are read from the **full** snapshot, not from the listed slice:
// a progress total is a property of the project, and a `--status`/`--filter`
// narrowing must not silently reduce it. `issues` is only the set of rows to
// render. This is the same snapshot source `projects_block` reads (contract
// §5: the two surfaces must report the same values), so a filtered listing and
// `tix show` cannot disagree.
let snapshot = store.list()?;
let (_index, reports) = crate::commands::view::project_reports(store, &snapshot)?;
let mut out = BTreeMap::new();
for issue in issues {
let Some(ids) = issue.projects.as_ref().filter(|ids| !ids.is_empty()) else {
continue;
};
let mut rendered: Vec<String> = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for raw in ids {
if seen.contains(&raw.as_str()) {
continue;
}
seen.push(raw.as_str());
match crate::config::ProjectId::new(raw)
.ok()
.and_then(|id| reports.get(&id))
{
Some(report) => rendered.push(format!(
"{} {}/{}{}",
crate::projects::project_display(&report.project_id, &report.name),
report.progress.done,
report.progress.total,
crate::commands::view::health_suffix(report),
)),
None => rendered.push(format!("{raw} (missing reference)")),
}
}
out.insert(issue.number, rendered.join("; "));
}
Ok(out)
}
/// Every issue's relationship facts, read from the service in one pass.
///
/// One graph for the whole board: deriving blocked state per row would mean
/// re-reading every file per row, and — worse — could let two rows in the same
/// table describe different snapshots.
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.human()))
.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 query(
store: &Store,
status: Option<&str>,
filter: Option<&str>,
with_archived: bool,
) -> Result<Vec<Issue>> {
// `--with-archived` opts into the exhaustive walk; the default reads the
// active set, whose dot-prefixed files are never opened (issue #1492).
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 {
// `parent:<sel>` names an issue by selector; matching happens against
// rendered frontmatter, which carries a number. Resolved here so `tix list
// parent:<sel>` behaves exactly as `tix search "parent:<sel>"` does, and so
// an unresolvable selector is the same error on both.
// `parent:` is a selector and `category:` is derived, so neither can be
// matched from the file's bytes. Both are resolved once here — the one layer
// with a store and a config — into the pair `matches_issue` needs (WP08
// T035). One call, so a site cannot validate the token and then forget to
// hand the criterion on: that mistake returns an empty board with exit 0.
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;