Skip to main content

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}