tix/ui/board/filter.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl ProjectPartition {
5 pub(super) fn build(config: &crate::config::Config, issues: &[Issue]) -> Self {
6 // Both halves come from the **service**, so the board cannot disagree with
7 // `tix list --group project` / `tix status --group project` about who is a
8 // member or which bucket labels them (WP09 item D): the memberships are
9 // [`crate::projects::ProjectIndex::resolved_memberships`], and the label is
10 // [`crate::projects::project_bucket_label`].
11 let index = crate::projects::ProjectIndex::build(config, issues);
12 let order: HashMap<String, usize> = index
13 .projects()
14 .iter()
15 .enumerate()
16 .map(|(position, project)| (project.id.to_string(), position))
17 .collect();
18 Self {
19 memberships: index.resolved_memberships(),
20 order,
21 }
22 }
23
24 /// The issue's resolved project ids, in declaration order.
25 pub(super) fn memberships(&self, number: u32) -> Vec<String> {
26 self.memberships.get(&number).cloned().unwrap_or_default()
27 }
28
29 /// The partition rank of one issue: a single membership sorts by its project's
30 /// declaration index, several sort after every declared project (and before
31 /// the no-project bucket), and none sorts last so `(no project)` is the final
32 /// section.
33 pub(super) fn rank(&self, number: u32) -> usize {
34 match self.memberships.get(&number).map(Vec::as_slice) {
35 None | Some([]) => usize::MAX,
36 Some([only]) => self.order.get(only).copied().unwrap_or(usize::MAX),
37 Some(_) => usize::MAX - 1,
38 }
39 }
40}
41
42/// The group-section label for one card's project membership (WP06 T028).
43///
44/// `(no project)` is the clearly labelled bucket T028 step 2 requires, and
45/// `(multiple projects)` is the bucket an issue in several projects lands in (see
46/// [`ProjectPartition`] for why it is one bucket rather than a duplicate per
47/// project). A single membership is labelled with the project **id**, because the
48/// id is what selects a project, so a reader can act on the header.
49pub(crate) fn project_bucket_label(projects: &[String]) -> String {
50 // The service owns the bucket rule (WP09 item D); this delegates rather than
51 // spelling the three labels a second time.
52 crate::projects::project_bucket_label(projects)
53}
54
55/// The epic partition of one board build (WP06 T030).
56///
57/// The exact analogue of [`ProjectPartition`] for the epic registry. Built once from
58/// the **shared** membership index ([`crate::epics::EpicIndex::build`]), so the
59/// board cannot disagree with `tix epic list` about who is a member: the resolved ids
60/// are the registry's, and a reference the registry does not know is not an epic here
61/// either — it is reported by `tix lint` instead, and its issue is bucketed
62/// `(no epic)`.
63///
64/// That last point is the same **chosen asymmetry** the project partition records:
65/// [`crate::search::StructuredFilter::epic_matches`] matches a stored reference
66/// **verbatim**, so `epic:<stale-id>` still *finds* the issues carrying a deleted
67/// epic's id (the repair affordance `tix epic remove` needs), while this partition —
68/// which answers "which epic owns this issue?" — has no bucket for an id nothing
69/// resolves to.
70///
71/// The bucket rules are the decision T029 step 4 records (one bucket per issue):
72///
73/// - an issue in **no** epic is its own clearly labelled `(no epic)` bucket;
74/// - an issue in exactly **one** epic sits in that epic's bucket;
75/// - an issue in **several** epics sits in a single `(multiple epics)` bucket rather
76/// than being duplicated into each epic's section.
77///
78/// Duplication is the road not taken for the reason [`ProjectPartition`] gives: one
79/// issue would become several cards, and the board's cursor, hit-testing and
80/// per-column counts are all index-into-one-list arithmetic. The **filter** still
81/// matches an issue in two epics under either id, so the union semantics are not lost
82/// where they matter.
83pub(crate) struct EpicPartition {
84 /// Issue number -> its resolved epic ids, registry declaration order.
85 pub(super) memberships: HashMap<u32, Vec<String>>,
86 /// Epic id -> its declaration index, for the sort rank.
87 pub(super) order: HashMap<String, usize>,
88}
89
90impl EpicPartition {
91 pub(super) fn build(config: &crate::config::Config, issues: &[Issue]) -> Self {
92 // Both halves come from the **service**: the index owns the membership rule
93 // (which references resolve), and `memberships_by_issue` is the service's own
94 // inverse of its buckets, in declaration order — so a filter, the CLI's
95 // `--group epic` and this partition cannot disagree about who is a member
96 // (WP09 item C).
97 let index = crate::epics::EpicIndex::build(config, issues);
98 let order: HashMap<String, usize> = index
99 .epics()
100 .iter()
101 .enumerate()
102 .map(|(position, epic)| (epic.id.to_string(), position))
103 .collect();
104 let memberships = index.memberships_by_issue();
105 Self { memberships, order }
106 }
107
108 /// The issue's resolved epic ids, in declaration order.
109 pub(super) fn memberships(&self, number: u32) -> Vec<String> {
110 self.memberships.get(&number).cloned().unwrap_or_default()
111 }
112
113 /// The partition rank of one issue: a single membership sorts by its epic's
114 /// declaration index, several sort after every declared epic (and before the
115 /// no-epic bucket), and none sorts last so `(no epic)` is the final section.
116 pub(super) fn rank(&self, number: u32) -> usize {
117 match self.memberships.get(&number).map(Vec::as_slice) {
118 None | Some([]) => usize::MAX,
119 Some([only]) => self.order.get(only).copied().unwrap_or(usize::MAX),
120 Some(_) => usize::MAX - 1,
121 }
122 }
123}
124
125/// The group-section label for one card's epic membership (WP06 T030).
126///
127/// Delegates to [`crate::search::epic_bucket_label`] rather than spelling the three
128/// labels a second time, so the board's header and the filter's vocabulary name the
129/// same bucket.
130pub(crate) fn epic_bucket_label(ids: &[String]) -> String {
131 crate::search::epic_bucket_label(ids)
132}
133
134impl crate::config::OrderBy {
135 /// Sort cards in place per the board's `order_by` setting. `none` is a
136 /// no-op. `sort_by` is stable, so equal creation dates keep the
137 /// underlying store order (relative filename ascending) — deterministic
138 /// ties with no extra key. Dates are zero-padded `YYYY-MM-DD`, so
139 /// lexicographic comparison is chronological.
140 pub fn sort(&self, cards: &mut [Card]) {
141 match self {
142 crate::config::OrderBy::CreatedAtDesc => {
143 cards.sort_by(|a, b| b.created.cmp(&a.created))
144 }
145 crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
146 crate::config::OrderBy::None => {}
147 }
148 }
149}
150
151pub(crate) fn sort_by_default_order(board_config: &BoardConfiguration, cards: &mut [Card]) {
152 match board_config.order_by {
153 crate::config::OrderBy::CreatedAtDesc => {
154 cards.sort_by(|a, b| {
155 b.created
156 .cmp(&a.created)
157 .then_with(|| a.filename.cmp(&b.filename))
158 });
159 }
160 crate::config::OrderBy::CreatedAtAsc => {
161 cards.sort_by(|a, b| {
162 a.created
163 .cmp(&b.created)
164 .then_with(|| a.filename.cmp(&b.filename))
165 });
166 }
167 crate::config::OrderBy::None => {
168 cards.sort_by(|a, b| a.filename.cmp(&b.filename));
169 }
170 }
171}
172
173pub(crate) fn sort_cards(board_config: &BoardConfiguration, cards: &mut [Card]) {
174 if board_config.card_order.is_empty() {
175 sort_by_default_order(board_config, cards);
176 return;
177 }
178
179 let mut baseline = cards.to_vec();
180 sort_by_default_order(board_config, &mut baseline);
181
182 let manual_cards: Vec<Card> = board_config
183 .card_order
184 .iter()
185 .filter_map(|filename| {
186 baseline
187 .iter()
188 .find(|card| card.filename == *filename)
189 .cloned()
190 })
191 .collect();
192 let touched: HashSet<String> = manual_cards
193 .iter()
194 .map(|card| card.filename.clone())
195 .collect();
196 let mut manual_iter = manual_cards.into_iter();
197 let mut ordered = Vec::with_capacity(baseline.len());
198
199 for card in baseline {
200 if touched.contains(card.filename.as_str()) {
201 if let Some(manual_card) = manual_iter.next() {
202 ordered.push(manual_card);
203 } else {
204 ordered.push(card);
205 }
206 } else {
207 ordered.push(card);
208 }
209 }
210
211 cards.clone_from_slice(&ordered);
212}
213
214/// Reorder one column so each card's sub-issues sit **immediately after it**,
215/// marking them `depth = 1` (tix #1399).
216///
217/// Only **one** level is ever nested. A card is placed under its parent only when
218/// that parent is itself a *top-level* card of this column — meaning the parent has
219/// no parent of its own present here. A grandchild (a card whose parent is itself a
220/// sub-issue) therefore stays `depth = 0` in its normal position rather than
221/// stacking under the child, so the tree cannot grow past one indent.
222///
223/// The ordering is settled **here, at build time**, not in the renderer: the
224/// selection index and the pointer hit-test both address cards by their index in
225/// the column, so a reorder only the renderer knew about would make a click or a
226/// `j` land on the wrong card.
227///
228/// Every card is emitted exactly once: a nested card is dropped from the top-level
229/// sequence and re-emitted under its parent, and nothing else moves.
230pub(crate) fn nest_children(cards: &mut Vec<Card>) {
231 let count = cards.len();
232 if count == 0 {
233 return;
234 }
235 let position: std::collections::HashMap<crate::id::IssueRef, usize> = cards
236 .iter()
237 .enumerate()
238 .map(|(i, card)| (card.reference, i))
239 .collect();
240 let mut adopted: Vec<Vec<usize>> = vec![Vec::new(); count];
241 let mut top_level: Vec<usize> = Vec::with_capacity(count);
242 for (index, card) in cards.iter().enumerate() {
243 match card
244 .parent
245 .and_then(|parent| position.get(&parent).copied())
246 {
247 Some(parent)
248 if cards[parent]
249 .parent
250 .is_none_or(|grandparent| !position.contains_key(&grandparent)) =>
251 {
252 adopted[parent].push(index);
253 }
254 _ => top_level.push(index),
255 }
256 }
257
258 let mut ordered: Vec<Card> = Vec::with_capacity(count);
259 for index in top_level {
260 let mut card = cards[index].clone();
261 card.depth = 0;
262 ordered.push(card);
263 for &child in &adopted[index] {
264 let mut child_card = cards[child].clone();
265 child_card.depth = 1;
266 ordered.push(child_card);
267 }
268 }
269 *cards = ordered;
270}
271
272impl BoardState {
273 /// Build a board from the search corpus, keeping only cards whose full
274 /// serialized text matches **both** the fuzzy `query` layer and the
275 /// structured `filter` layer (FR-012/FR-013). Both predicates run in a
276 /// single pass over the corpus via [`crate::search::matches`] (NFR-001).
277 ///
278 /// When a filter is active (non-empty query or structured criteria set),
279 /// hidden columns' matching cards are **revealed** in the rendered surface
280 /// (DM-01M2MHJB884WCTVCDHPD2T3GE7: hidden-column cards reachable via
281 /// filters/search). When no filter is active, matching cards from hidden
282 /// columns are stashed in [`BoardState::hidden`] (no visual presence).
283 pub fn build_filtered(
284 board_config: &BoardConfiguration,
285 config: &crate::config::Config,
286 corpus: &Corpus,
287 query: &str,
288 filter: &StructuredFilter,
289 resolution: &crate::workflow::CategoryResolution,
290 ) -> Self {
291 Self::build_filtered_with(
292 board_config,
293 config,
294 corpus,
295 query,
296 filter,
297 resolution,
298 false,
299 )
300 }
301
302 /// [`BoardState::build_filtered`], optionally rendering sub-issues under their
303 /// parents (tix #1399).
304 ///
305 /// The `u` toggle is global, so it must keep working while a search or
306 /// structured filter is active: `build_filtered` delegates here with
307 /// `show_children == false` and the TUI passes its flag through.
308 pub fn build_filtered_with(
309 board_config: &BoardConfiguration,
310 config: &crate::config::Config,
311 corpus: &Corpus,
312 query: &str,
313 filter: &StructuredFilter,
314 resolution: &crate::workflow::CategoryResolution,
315 show_children: bool,
316 ) -> Self {
317 let show_mismatch = board_config.show_mismatch;
318 // WP09, as in `build`: the service's completion column, so the filtered and
319 // unfiltered boards agree on which column is blocked and which is complete.
320 let done_column = crate::workflow::completion_column_or_default(board_config, config);
321 let done_column = done_column.as_str();
322 // Built from the corpus, not from the filtered slice below: a blocker the
323 // query happens to exclude must still count as a blocker.
324 let blocked = blocked_reasons(&corpus.issues, done_column);
325 // Same reason, same discipline: a child the query excludes is still a
326 // child, so a parent's marker must not shrink because the board is
327 // filtered.
328 let children = child_markers(&corpus.issues, done_column);
329 let filtering = !query.is_empty() || filter.has_active_criteria();
330 // A `group:<key>` with no explicit `sort:<key>` sorts by that key
331 // (WP06 T028). A partition is only contiguous — and only in the key's own
332 // order — when the cards are ordered by the same key, and the contract for
333 // the project partition names the order explicitly ("declaration order,
334 // no-project bucket last"). Without this, `group:project` alone would emit
335 // one section per created-date run and the order would depend on the user
336 // remembering to type `sort:project` as well.
337 //
338 // An explicit `sort:` still wins, so `group:cycle sort:due` keeps meaning
339 // what it meant.
340 let sort_key = crate::search::control_token(query, "sort")
341 .or_else(|| crate::search::control_token(query, "group"));
342 // The project partition, resolved once from the shared membership index
343 // (NFR-001): the sorter and the renderer both read this, so there is no
344 // second membership read and no scan per criterion.
345 let projects = ProjectPartition::build(config, &corpus.issues);
346 // The epic partition, read once from the same shared index as `projects`
347 // (WP06 T030): the sorter and the renderer both read this, so there is no
348 // second membership read.
349 let epics = EpicPartition::build(config, &corpus.issues);
350 // A bare-number query (all-ASCII-digit after trim) is an exact issue
351 // number match, not a fuzzy digit search: only the issue whose
352 // `number` equals it survives, still ANDed with the structured
353 // filter (board-filtering FR-02 / FR-03). Any other query keeps the
354 // fuzzy-text path.
355 let number = crate::search::number_query(query);
356 let hidden_names: Vec<&str> = board_config
357 .hidden_columns
358 .iter()
359 .map(String::as_str)
360 .collect();
361 let mut column_map: Vec<Vec<Card>> =
362 board_config.columns.iter().map(|_| Vec::new()).collect();
363 let mut mismatch_cards = Vec::new();
364 let mut matched_issues: Vec<&crate::model::Issue> = Vec::new();
365
366 for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
367 // Bare-number query: exact issue-number match replaces the fuzzy
368 // text layer (board-filtering FR-02); the structured filter still
369 // runs (FR-03). Otherwise the composite fuzzy+structured
370 // predicate applies unchanged.
371 // `matches_issue`, not `matches` (WP08 T035): the TUI filter modal can
372 // set a `category` criterion, and `matches` cannot judge it — it would
373 // silently drop the criterion and show the **unfiltered** board while
374 // the modal showed `category: completed` as active. The board already
375 // holds the `resolution` (it is a parameter, because WP05 needed it for
376 // the card labels), so the criterion costs one hash lookup per issue.
377 let keep = if let Some(n) = number {
378 issue.number == n && filter.evaluate_issue(issue, text, resolution)
379 } else {
380 crate::search::matches_issue(query, filter, issue, text, resolution)
381 };
382 if !keep {
383 continue;
384 }
385 matched_issues.push(issue);
386 }
387
388 Self::sort_filtered_issues(&mut matched_issues, sort_key.as_deref(), &projects, &epics);
389
390 let health_badges = health_badges(&corpus.issues);
391 for issue in matched_issues {
392 let card = Card {
393 reference: crate::id::canonical_ref(issue),
394 number: issue.number,
395 title: issue.title.clone(),
396 priority: issue.priority.clone(),
397 labels: issue.labels.clone(),
398 assignee: issue.assignee.clone(),
399 filename: issue.filename.clone(),
400 commit: issue.commit.clone(),
401 created: issue.created.clone(),
402 due_date: issue.due_date.clone(),
403 status: issue.status.display().to_string(),
404 category: crate::workflow::category_of(issue, resolution)
405 .map(|c| c.label().to_string()),
406 category_color: crate::workflow::category_of(issue, resolution)
407 .and_then(|c| c.color_hint()),
408 done: crate::workflow::is_completed(issue, resolution),
409 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
410 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
411 blocked: blocked.get(&issue.number).cloned(),
412 children: children.get(&issue.number).cloned(),
413 parent: issue.parent,
414 depth: 0,
415 archived: issue.archived,
416 related: issue.related_issues.len(),
417 health: health_badges.get(&issue.number).cloned(),
418 projects: projects.memberships(issue.number),
419 epics: epics.memberships(issue.number),
420 };
421 if let Some(idx) = board_config
422 .columns
423 .iter()
424 .position(|c| c == issue.status.display())
425 {
426 column_map[idx].push(card);
427 } else {
428 mismatch_cards.push(card);
429 }
430 }
431
432 if sort_key.is_none() {
433 for cards in column_map.iter_mut() {
434 sort_cards(board_config, cards);
435 }
436 sort_cards(board_config, &mut mismatch_cards);
437 }
438
439 let mut columns: Vec<BoardColumn> = Vec::new();
440 let mut hidden: Vec<BoardColumn> = Vec::new();
441 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
442 let col = BoardColumn {
443 name: name.clone(),
444 cards,
445 };
446 if hidden_names.contains(&name.as_str()) {
447 if filtering {
448 // Reveal hidden column when a filter is active so
449 // matching cards appear in the rendered surface
450 // (DM-01M2MHJB884WCTVCDHPD2T3GE7).
451 columns.push(col);
452 } else {
453 hidden.push(col);
454 }
455 } else {
456 columns.push(col);
457 }
458 }
459
460 let mut state = BoardState {
461 columns,
462 hidden,
463 mismatch: BoardColumn {
464 name: "Mismatched".to_string(),
465 cards: mismatch_cards,
466 },
467 show_mismatch,
468 active_col: 0,
469 selected: 0,
470 filter: Some(query.to_string()),
471 hover: None,
472 hover_col: None,
473 };
474 state.apply_child_order(show_children);
475 state
476 }
477
478 pub(super) fn sort_filtered_issues(
479 issues: &mut [&crate::model::Issue],
480 sort: Option<&str>,
481 projects: &ProjectPartition,
482 epics: &EpicPartition,
483 ) {
484 match sort.unwrap_or("created") {
485 // The project partition order (WP06 T028): registry declaration order
486 // for a single membership, after every declared project for an issue in
487 // several, and last of all for an issue in none — which is exactly the
488 // order the buckets are rendered in, so the sections come out
489 // contiguous and the `(no project)` bucket is last.
490 "project" => issues.sort_by(|a, b| {
491 projects
492 .rank(a.number)
493 .cmp(&projects.rank(b.number))
494 .then(a.number.cmp(&b.number))
495 }),
496 // The epic partition order (WP06 T030): the exact analogue of `project`
497 // above, so `group:epic` alone emits one contiguous section per epic in
498 // declaration order, then `(multiple epics)`, then `(no epic)` last.
499 "epic" => issues.sort_by(|a, b| {
500 epics
501 .rank(a.number)
502 .cmp(&epics.rank(b.number))
503 .then(a.number.cmp(&b.number))
504 }),
505 "due" => {
506 issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number)))
507 }
508 "start" => issues.sort_by(|a, b| {
509 a.start_date
510 .cmp(&b.start_date)
511 .then(a.number.cmp(&b.number))
512 }),
513 "reviewer" => {
514 issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
515 }
516 "cycle" => issues.sort_by(|a, b| {
517 a.cycles
518 .as_ref()
519 .and_then(|v| v.first())
520 .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
521 .then(a.number.cmp(&b.number))
522 }),
523 "milestone" => issues.sort_by(|a, b| {
524 a.milestones
525 .as_ref()
526 .and_then(|v| v.first())
527 .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
528 .then(a.number.cmp(&b.number))
529 }),
530 "status" => issues.sort_by(|a, b| {
531 a.status
532 .display()
533 .cmp(b.status.display())
534 .then(a.number.cmp(&b.number))
535 }),
536 "priority" => issues.sort_by(|a, b| {
537 a.priority
538 .to_string()
539 .cmp(&b.priority.to_string())
540 .then(a.number.cmp(&b.number))
541 }),
542 _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
543 }
544 }
545}