Skip to main content

tix/ui/
board.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6use std::collections::HashSet;
7
8use crate::config::BoardConfiguration;
9use crate::model::Priority;
10use crate::search::{Corpus, StructuredFilter};
11use crate::storage::Store;
12
13use super::widgets;
14
15#[derive(Debug, Clone)]
16struct GroupSection {
17    name: String,
18    start: usize,
19    end: usize,
20    done: usize,
21    total: usize,
22}
23
24#[derive(Debug, Clone)]
25pub struct Card {
26    pub number: u32,
27    pub title: String,
28    pub priority: Priority,
29    pub labels: Vec<String>,
30    pub assignee: Option<String>,
31    pub filename: String,
32    pub commit: Option<String>,
33    pub created: String,
34    pub due_date: Option<String>,
35    pub status: String,
36    /// The issue's resolved category label, from the service's own `label()`
37    /// (WP05 T022, FR-004).
38    ///
39    /// `Some` for a column the board configures, `None` for one it does not — a
40    /// configuration error the user should be able to *see*, rather than a
41    /// transition-policy default silently applied to a display. The renderer shows
42    /// `(unmapped)` for `None`.
43    ///
44    /// **The label carries the meaning, not the color.** A board or terminal with no
45    /// color must still convey the category, so the label is always rendered and the
46    /// `color_hint` only ever tints it (T022 step 3, research R6).
47    pub category: Option<String>,
48    /// The category's color hint, for tinting. Advisory only: dropping it changes
49    /// the display's color and nothing else.
50    pub category_color: Option<crate::workflow::ColorHint>,
51    /// Whether the service considers this issue **complete** (WP05, C-004).
52    ///
53    /// Was `issue.status.is(done_column)` — a column-*name* comparison. On a board
54    /// whose `category_map` puts `completed` somewhere other than its `done_column`
55    /// that is a different question from the one the service answers, and this flag
56    /// drives the card's completion styling and the group's rollup.
57    pub done: bool,
58    pub cycle: Option<String>,
59    pub milestone: Option<String>,
60    /// The issue's blocked reason, from the relationship service, or `None`.
61    ///
62    /// Derived, never stored (data-model §4, C-003): recomputed from the graph
63    /// on every board build, so a card cannot show a stale blocked state after
64    /// its blocker moved.
65    pub blocked: Option<String>,
66    /// The issue's hierarchy marker: `3` for three children, `3 1/3` when one is
67    /// complete, or `None` for a leaf.
68    ///
69    /// `None` for a leaf rather than `Some("0")`, so a card cannot show a child
70    /// count for an issue that is not a parent. Built from the service's
71    /// [`crate::commands::parent::HierarchyFacts`] on every board build, so it
72    /// cannot go stale when a child is created or moved — the same discipline
73    /// `blocked` follows.
74    pub children: Option<String>,
75    /// Whether this issue is **archived** — its file is dot-prefixed (issue #1492).
76    ///
77    /// A card only ever carries `true` on a board built with archived issues
78    /// included (the TUI's archive toggle); the default board is enumerated from
79    /// the active set and never sees one. Rendered as the `[archived]` token, in the
80    /// same bracketed language as `[~]` and `[N]`.
81    pub archived: bool,
82}
83
84impl crate::config::OrderBy {
85    /// Sort cards in place per the board's `order_by` setting. `none` is a
86    /// no-op. `sort_by` is stable, so equal creation dates keep the
87    /// underlying store order (relative filename ascending) — deterministic
88    /// ties with no extra key. Dates are zero-padded `YYYY-MM-DD`, so
89    /// lexicographic comparison is chronological.
90    pub fn sort(&self, cards: &mut [Card]) {
91        match self {
92            crate::config::OrderBy::CreatedAtDesc => {
93                cards.sort_by(|a, b| b.created.cmp(&a.created))
94            }
95            crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
96            crate::config::OrderBy::None => {}
97        }
98    }
99}
100
101fn sort_by_default_order(board_config: &BoardConfiguration, cards: &mut [Card]) {
102    match board_config.order_by {
103        crate::config::OrderBy::CreatedAtDesc => {
104            cards.sort_by(|a, b| {
105                b.created
106                    .cmp(&a.created)
107                    .then_with(|| a.filename.cmp(&b.filename))
108            });
109        }
110        crate::config::OrderBy::CreatedAtAsc => {
111            cards.sort_by(|a, b| {
112                a.created
113                    .cmp(&b.created)
114                    .then_with(|| a.filename.cmp(&b.filename))
115            });
116        }
117        crate::config::OrderBy::None => {
118            cards.sort_by(|a, b| a.filename.cmp(&b.filename));
119        }
120    }
121}
122
123fn sort_cards(board_config: &BoardConfiguration, cards: &mut [Card]) {
124    if board_config.card_order.is_empty() {
125        sort_by_default_order(board_config, cards);
126        return;
127    }
128
129    let mut baseline = cards.to_vec();
130    sort_by_default_order(board_config, &mut baseline);
131
132    let manual_cards: Vec<Card> = board_config
133        .card_order
134        .iter()
135        .filter_map(|filename| {
136            baseline
137                .iter()
138                .find(|card| card.filename == *filename)
139                .cloned()
140        })
141        .collect();
142    let touched: HashSet<String> = manual_cards
143        .iter()
144        .map(|card| card.filename.clone())
145        .collect();
146    let mut manual_iter = manual_cards.into_iter();
147    let mut ordered = Vec::with_capacity(baseline.len());
148
149    for card in baseline {
150        if touched.contains(card.filename.as_str()) {
151            if let Some(manual_card) = manual_iter.next() {
152                ordered.push(manual_card);
153            } else {
154                ordered.push(card);
155            }
156        } else {
157            ordered.push(card);
158        }
159    }
160
161    cards.clone_from_slice(&ordered);
162}
163
164pub(crate) fn sparse_card_order(board_config: &BoardConfiguration, cards: &[Card]) -> Vec<String> {
165    let mut baseline = cards.to_vec();
166    sort_by_default_order(board_config, &mut baseline);
167    baseline
168        .iter()
169        .zip(cards.iter())
170        .filter(|(baseline_card, current_card)| baseline_card.filename != current_card.filename)
171        .map(|(_, current_card)| current_card.filename.clone())
172        .collect()
173}
174
175#[derive(Debug, Clone)]
176pub struct BoardColumn {
177    pub name: String,
178    pub cards: Vec<Card>,
179}
180
181/// What a pointer position over the board resolves to (D3). `row` is the
182/// card's index inside its column — the same index [`BoardState::selected`]
183/// uses — so the caller can compare it directly against the selection.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum HitTarget {
186    Card { col: usize, row: usize },
187    Column { col: usize },
188}
189
190#[derive(Debug, Clone)]
191pub struct BoardState {
192    /// Visible columns only — configured columns not listed in the active
193    /// board's `hidden_columns`, in declaration order (FR-002/FR-003). Hidden
194    /// columns live in [`BoardState::hidden`].
195    pub columns: Vec<BoardColumn>,
196    /// Columns hidden from the active board configuration. Their cards stay
197    /// in board state (keyed by column name) and remain reachable via
198    /// filters/search (DM-01M2MHJB884WCTVCDHPD2T3GE7); they never render.
199    pub hidden: Vec<BoardColumn>,
200    pub mismatch: BoardColumn,
201    /// Whether the computed "Mismatched" pseudo-column is part of the
202    /// navigation/render surface (C-002 / FR-003). Cards in unknown statuses
203    /// are always collected into [`BoardState::mismatch`]; the column is only
204    /// shown when the effective configuration sets `show_mismatch`.
205    pub show_mismatch: bool,
206    pub active_col: usize,
207    pub selected: usize,
208    /// The `(col, card index)` the pointer is hovering (D8), set by `Moved`
209    /// events and cleared on `Up` / when the pointer leaves the board.
210    /// Rendered as a highlight only — it never changes the selection.
211    pub hover: Option<(usize, usize)>,
212    pub filter: Option<String>,
213}
214
215/// Shorten `msg` to at most `budget` **display cells**, appending an ellipsis
216/// when anything was dropped.
217///
218/// The marker is appended *within* the budget rather than after it, so the result
219/// is never wider than the area it is rendered into. A combining mark or a
220/// zero-width control contributes no width, so it is kept without consuming
221/// budget rather than being dropped.
222pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
223    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
224
225    const ELLIPSIS: char = '\u{2026}';
226    if budget == 0 {
227        return String::new();
228    }
229    if UnicodeWidthStr::width(msg) <= budget {
230        return msg.to_string();
231    }
232
233    // Reserve one cell for the marker. A double-width character cannot fit in
234    // the last cell, so the loop stops on a width overflow rather than on a
235    // character count.
236    let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
237    let mut out = String::new();
238    let mut used = 0usize;
239    for ch in msg.chars() {
240        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
241        if used + w + marker_width > budget {
242            break;
243        }
244        out.push(ch);
245        used += w;
246    }
247    out.push(ELLIPSIS);
248    out
249}
250
251/// The blocked reason for every issue, read from the relationship service.
252///
253/// One graph over the **whole** set, so the answer does not depend on which
254/// issues the board happens to be showing (see [`BoardState::blocked`]).
255fn blocked_reasons(
256    issues: &[crate::model::Issue],
257    done_column: &str,
258) -> std::collections::BTreeMap<u32, String> {
259    let views: Vec<crate::relationships::IssueView<'_>> =
260        issues.iter().map(crate::model::Issue::issue_view).collect();
261    let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
262    issues
263        .iter()
264        .filter_map(|issue| {
265            crate::commands::blocked_note(&graph.blocked_state(issue.number))
266                .map(|note| (issue.number, note))
267        })
268        .collect()
269}
270
271/// The child-count marker for every issue that has children, keyed by number.
272///
273/// One index over the whole set, so the marker does not depend on which issues the
274/// board is showing. The text comes from the shared `HierarchyFacts`, which is what
275/// `tix parent show`, `tix show` and `tix list --show progress` read, so the four
276/// surfaces cannot report different figures.
277fn child_markers(
278    issues: &[crate::model::Issue],
279    done_column: &str,
280) -> std::collections::BTreeMap<u32, String> {
281    let views: Vec<crate::relationships::IssueView<'_>> =
282        issues.iter().map(crate::model::Issue::issue_view).collect();
283    let index = crate::hierarchy::HierarchyIndex::build(&views);
284    issues
285        .iter()
286        .filter_map(|issue| {
287            let facts =
288                crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
289            facts.has_children().then(|| {
290                (
291                    issue.number,
292                    format!(
293                        "{} {}/{}",
294                        facts.rollup.direct_children,
295                        facts.rollup.completed_children,
296                        facts.rollup.progress.total
297                    ),
298                )
299            })
300        })
301        .collect()
302}
303
304impl BoardState {
305    /// WP05: the resolution must be for the board this call is building, not for
306    /// whichever board happens to be `default`.
307    ///
308    /// `board_config` can be any of the configured boards, and a board selected in
309    /// the TUI is not necessarily the default one. Resolving against the default
310    /// would give a card on the `staging` board the categories configured for
311    /// `default` — a wrong answer rather than a missing one, which is worse.
312    ///
313    /// The board is identified by **pointer identity** against the config's boards,
314    /// because `build` receives a `&BoardConfiguration` and not a name. That is exact
315    /// and needs no name plumbing; a board not found in the config falls back to its
316    /// own columns with the global vocabulary, which is the honest reading of a
317    /// configuration `tix` did not load.
318    fn resolution_for(
319        store: &Store,
320        board_config: &BoardConfiguration,
321    ) -> crate::workflow::CategoryResolution {
322        let name = store
323            .config
324            .configs()
325            .find(|(_, board)| std::ptr::eq(*board, board_config))
326            .map(|(name, _)| name)
327            .unwrap_or("default");
328        crate::workflow::resolution(&store.config, board_config, name)
329    }
330
331    pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
332        Self::build_with_archived(store, board_config, false)
333    }
334
335    /// [`BoardState::build`], optionally including **archived** issues (issue #1492).
336    ///
337    /// `include_archived == false` is the default and the fast path: enumeration reads
338    /// [`Store::list`], which drops a dot-prefixed filename from the directory entry
339    /// without opening the file. `true` reads [`Store::list_all`], which is what the
340    /// TUI's archive toggle asks for *on demand* — the point being that a repository
341    /// with thousands of archived issues is never walked for a board that does not
342    /// show them.
343    pub fn build_with_archived(
344        store: &Store,
345        board_config: &BoardConfiguration,
346        include_archived: bool,
347    ) -> Self {
348        let issues = if include_archived {
349            store.list_all()
350        } else {
351            store.list()
352        }
353        .unwrap_or_default();
354        // WP09: the **service's** completion column, not `done_column`. The
355        // `blocked_reasons` and `child_markers` helpers take a column *name* — that is
356        // their existing signature and it is not what makes them wrong. What was
357        // wrong is which name they were handed: on a board whose `category_map` puts
358        // `completed` on `shipped` while `done_column` is `done`, the TUI's blocked
359        // marker was computed against `done_column` while `tix list`'s was computed
360        // against the service, so the same issue read as blocked in the TUI and clear
361        // in the CLI. The name is now resolved once, by the service.
362        let done_column =
363            crate::workflow::completion_column_or_default(board_config, &store.config);
364        let done_column = done_column.as_str();
365        let resolution = Self::resolution_for(store, board_config);
366        let blocked = blocked_reasons(&issues, done_column);
367        let children = child_markers(&issues, done_column);
368        let show_mismatch = board_config.show_mismatch;
369        let hidden_names: Vec<&str> = board_config
370            .hidden_columns
371            .iter()
372            .map(String::as_str)
373            .collect();
374        let mut column_map: Vec<Vec<Card>> =
375            board_config.columns.iter().map(|_| Vec::new()).collect();
376        let mut mismatch_cards = Vec::new();
377
378        for issue in issues {
379            let card = Card {
380                number: issue.number,
381                title: issue.title.clone(),
382                priority: issue.priority.clone(),
383                labels: issue.labels.clone(),
384                assignee: issue.assignee.clone(),
385                filename: issue.filename.clone(),
386                commit: issue.commit.clone(),
387                created: issue.created.clone(),
388                due_date: issue.due_date.clone(),
389                status: issue.status.display().to_string(),
390                category: crate::workflow::category_of(&issue, &resolution)
391                    .map(|c| c.label().to_string()),
392                category_color: crate::workflow::category_of(&issue, &resolution)
393                    .and_then(|c| c.color_hint()),
394                done: crate::workflow::is_completed(&issue, &resolution),
395                cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
396                milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
397                blocked: blocked.get(&issue.number).cloned(),
398                children: children.get(&issue.number).cloned(),
399                archived: issue.archived,
400            };
401            if let Some(idx) = board_config
402                .columns
403                .iter()
404                .position(|c| c == issue.status.display())
405            {
406                column_map[idx].push(card);
407            } else {
408                mismatch_cards.push(card);
409            }
410        }
411
412        for cards in column_map.iter_mut() {
413            sort_cards(board_config, cards);
414        }
415        sort_cards(board_config, &mut mismatch_cards);
416
417        let mut columns: Vec<BoardColumn> = Vec::new();
418        let mut hidden: Vec<BoardColumn> = Vec::new();
419        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
420            let col = BoardColumn {
421                name: name.clone(),
422                cards,
423            };
424            if hidden_names.contains(&name.as_str()) {
425                hidden.push(col);
426            } else {
427                columns.push(col);
428            }
429        }
430
431        let mismatch = BoardColumn {
432            name: "Mismatched".to_string(),
433            cards: mismatch_cards,
434        };
435
436        BoardState {
437            columns,
438            hidden,
439            mismatch,
440            show_mismatch,
441            active_col: 0,
442            selected: 0,
443            filter: None,
444            hover: None,
445        }
446    }
447
448    /// Build a board from the search corpus, keeping only cards whose full
449    /// serialized text matches **both** the fuzzy `query` layer and the
450    /// structured `filter` layer (FR-012/FR-013). Both predicates run in a
451    /// single pass over the corpus via [`crate::search::matches`] (NFR-001).
452    ///
453    /// When a filter is active (non-empty query or structured criteria set),
454    /// hidden columns' matching cards are **revealed** in the rendered surface
455    /// (DM-01M2MHJB884WCTVCDHPD2T3GE7: hidden-column cards reachable via
456    /// filters/search). When no filter is active, matching cards from hidden
457    /// columns are stashed in [`BoardState::hidden`] (no visual presence).
458    pub fn build_filtered(
459        board_config: &BoardConfiguration,
460        config: &crate::config::Config,
461        corpus: &Corpus,
462        query: &str,
463        filter: &StructuredFilter,
464        resolution: &crate::workflow::CategoryResolution,
465    ) -> Self {
466        let show_mismatch = board_config.show_mismatch;
467        // WP09, as in `build`: the service's completion column, so the filtered and
468        // unfiltered boards agree on which column is blocked and which is complete.
469        let done_column = crate::workflow::completion_column_or_default(board_config, config);
470        let done_column = done_column.as_str();
471        // Built from the corpus, not from the filtered slice below: a blocker the
472        // query happens to exclude must still count as a blocker.
473        let blocked = blocked_reasons(&corpus.issues, done_column);
474        // Same reason, same discipline: a child the query excludes is still a
475        // child, so a parent's marker must not shrink because the board is
476        // filtered.
477        let children = child_markers(&corpus.issues, done_column);
478        let filtering = !query.is_empty() || filter.has_active_criteria();
479        let sort_key = crate::search::control_token(query, "sort");
480        // A bare-number query (all-ASCII-digit after trim) is an exact issue
481        // number match, not a fuzzy digit search: only the issue whose
482        // `number` equals it survives, still ANDed with the structured
483        // filter (board-filtering FR-02 / FR-03). Any other query keeps the
484        // fuzzy-text path.
485        let number = crate::search::number_query(query);
486        let hidden_names: Vec<&str> = board_config
487            .hidden_columns
488            .iter()
489            .map(String::as_str)
490            .collect();
491        let mut column_map: Vec<Vec<Card>> =
492            board_config.columns.iter().map(|_| Vec::new()).collect();
493        let mut mismatch_cards = Vec::new();
494        let mut matched_issues: Vec<&crate::model::Issue> = Vec::new();
495
496        for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
497            // Bare-number query: exact issue-number match replaces the fuzzy
498            // text layer (board-filtering FR-02); the structured filter still
499            // runs (FR-03). Otherwise the composite fuzzy+structured
500            // predicate applies unchanged.
501            // `matches_issue`, not `matches` (WP08 T035): the TUI filter modal can
502            // set a `category` criterion, and `matches` cannot judge it — it would
503            // silently drop the criterion and show the **unfiltered** board while
504            // the modal showed `category: completed` as active. The board already
505            // holds the `resolution` (it is a parameter, because WP05 needed it for
506            // the card labels), so the criterion costs one hash lookup per issue.
507            let keep = if let Some(n) = number {
508                issue.number == n && filter.evaluate_issue(issue, text, resolution)
509            } else {
510                crate::search::matches_issue(query, filter, issue, text, resolution)
511            };
512            if !keep {
513                continue;
514            }
515            matched_issues.push(issue);
516        }
517
518        Self::sort_filtered_issues(&mut matched_issues, sort_key.as_deref());
519
520        for issue in matched_issues {
521            let card = Card {
522                number: issue.number,
523                title: issue.title.clone(),
524                priority: issue.priority.clone(),
525                labels: issue.labels.clone(),
526                assignee: issue.assignee.clone(),
527                filename: issue.filename.clone(),
528                commit: issue.commit.clone(),
529                created: issue.created.clone(),
530                due_date: issue.due_date.clone(),
531                status: issue.status.display().to_string(),
532                category: crate::workflow::category_of(issue, resolution)
533                    .map(|c| c.label().to_string()),
534                category_color: crate::workflow::category_of(issue, resolution)
535                    .and_then(|c| c.color_hint()),
536                done: crate::workflow::is_completed(issue, resolution),
537                cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
538                milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
539                blocked: blocked.get(&issue.number).cloned(),
540                children: children.get(&issue.number).cloned(),
541                archived: issue.archived,
542            };
543            if let Some(idx) = board_config
544                .columns
545                .iter()
546                .position(|c| c == issue.status.display())
547            {
548                column_map[idx].push(card);
549            } else {
550                mismatch_cards.push(card);
551            }
552        }
553
554        if sort_key.is_none() {
555            for cards in column_map.iter_mut() {
556                sort_cards(board_config, cards);
557            }
558            sort_cards(board_config, &mut mismatch_cards);
559        }
560
561        let mut columns: Vec<BoardColumn> = Vec::new();
562        let mut hidden: Vec<BoardColumn> = Vec::new();
563        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
564            let col = BoardColumn {
565                name: name.clone(),
566                cards,
567            };
568            if hidden_names.contains(&name.as_str()) {
569                if filtering {
570                    // Reveal hidden column when a filter is active so
571                    // matching cards appear in the rendered surface
572                    // (DM-01M2MHJB884WCTVCDHPD2T3GE7).
573                    columns.push(col);
574                } else {
575                    hidden.push(col);
576                }
577            } else {
578                columns.push(col);
579            }
580        }
581
582        BoardState {
583            columns,
584            hidden,
585            mismatch: BoardColumn {
586                name: "Mismatched".to_string(),
587                cards: mismatch_cards,
588            },
589            show_mismatch,
590            active_col: 0,
591            selected: 0,
592            filter: Some(query.to_string()),
593            hover: None,
594        }
595    }
596
597    fn sort_filtered_issues(issues: &mut [&crate::model::Issue], sort: Option<&str>) {
598        match sort.unwrap_or("created") {
599            "due" => {
600                issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number)))
601            }
602            "start" => issues.sort_by(|a, b| {
603                a.start_date
604                    .cmp(&b.start_date)
605                    .then(a.number.cmp(&b.number))
606            }),
607            "reviewer" => {
608                issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
609            }
610            "cycle" => issues.sort_by(|a, b| {
611                a.cycles
612                    .as_ref()
613                    .and_then(|v| v.first())
614                    .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
615                    .then(a.number.cmp(&b.number))
616            }),
617            "milestone" => issues.sort_by(|a, b| {
618                a.milestones
619                    .as_ref()
620                    .and_then(|v| v.first())
621                    .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
622                    .then(a.number.cmp(&b.number))
623            }),
624            "status" => issues.sort_by(|a, b| {
625                a.status
626                    .display()
627                    .cmp(b.status.display())
628                    .then(a.number.cmp(&b.number))
629            }),
630            "priority" => issues.sort_by(|a, b| {
631                a.priority
632                    .to_string()
633                    .cmp(&b.priority.to_string())
634                    .then(a.number.cmp(&b.number))
635            }),
636            _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
637        }
638    }
639
640    /// True when no card anywhere is visible (used for zero-match feedback).
641    pub fn is_empty(&self) -> bool {
642        self.columns.iter().all(|c| c.cards.is_empty())
643            && (!self.show_mismatch || self.mismatch.cards.is_empty())
644    }
645
646    pub fn total_cols(&self) -> usize {
647        if self.show_mismatch {
648            self.columns.len() + 1
649        } else {
650            self.columns.len()
651        }
652    }
653
654    /// Map a terminal pointer `(x, y)` — given against the same `area` passed
655    /// to [`render`] — to the card or column underneath. `status` mirrors
656    /// whether [`render`] is drawing a status message, so the status row (not
657    /// part of any column) never resolves to a target. Returns `None` for
658    /// positions outside the board area (`y` above the columns or beyond the
659    /// board) or when nothing is drawn. Uses [`compute_column_rects`] and
660    /// [`super::widgets::card_line_count`] — the geometry `render` draws with —
661    /// so the hit-tested layout matches the rendered one by construction (D3).
662    pub fn hit_test(
663        &self,
664        area: Rect,
665        display: BoardDisplay,
666        status: bool,
667        x: u16,
668        y: u16,
669    ) -> Option<HitTarget> {
670        if area.height < 2 || area.width < 4 || self.total_cols() == 0 {
671            return None;
672        }
673        let board_height = area.height.saturating_sub(u16::from(status));
674        if y >= area.y + board_height {
675            return None;
676        }
677        let board_area = Rect {
678            height: board_height,
679            ..area
680        };
681
682        let rects = compute_column_rects(board_area, self.total_cols());
683        let (col, col_rect) = rects
684            .iter()
685            .enumerate()
686            .find(|(_, r)| r.contains(Position::new(x, y)))?;
687
688        let cards: &[Card] = if col < self.columns.len() {
689            &self.columns[col].cards
690        } else {
691            &self.mismatch.cards
692        };
693
694        let group_mode = self
695            .filter
696            .as_deref()
697            .and_then(|q| crate::search::control_token(q, "group"));
698
699        // Items stack top-down exactly as `render` pushes them: group headers
700        // occupy one row each, cards occupy `card_line_count` rows.
701        let mut items: Vec<Option<usize>> = Vec::new();
702        if let Some(mode) = group_mode.as_deref() {
703            if let Some(sections) = Self::group_sections(cards, Some(mode)) {
704                for section in sections {
705                    items.push(None);
706                    for (j, card) in cards
707                        .iter()
708                        .enumerate()
709                        .take(section.end)
710                        .skip(section.start)
711                    {
712                        let count = widgets::card_line_count(
713                            card,
714                            col_rect.width as usize,
715                            display.wrap_titles,
716                            display.title_lines,
717                        );
718                        for _ in 0..count {
719                            items.push(Some(j));
720                        }
721                    }
722                }
723            }
724        } else {
725            for (j, card) in cards.iter().enumerate() {
726                let count = widgets::card_line_count(
727                    card,
728                    col_rect.width as usize,
729                    display.wrap_titles,
730                    display.title_lines,
731                );
732                for _ in 0..count {
733                    items.push(Some(j));
734                }
735            }
736        }
737
738        let has_footer = BoardState::group_footer(cards, group_mode.as_deref()).is_some();
739        let list_height = col_rect.height.saturating_sub(u16::from(has_footer));
740        // Content rows live between the block's top border and bottom border.
741        let content_top = col_rect.y + 1;
742        let content_end = col_rect.y + list_height.saturating_sub(1);
743        if y >= content_top && y < content_end {
744            let offset = usize::from(y - content_top);
745            if let Some(Some(row)) = items.get(offset) {
746                return Some(HitTarget::Card { col, row: *row });
747            }
748        }
749        Some(HitTarget::Column { col })
750    }
751
752    /// Columns a card can be moved into: the configured columns plus (when
753    /// shown) the "Mismatched" sentinel, which is never a move target. The
754    /// `</>` move keys navigate *among movable columns* only.
755    pub fn real_cols(&self) -> usize {
756        self.columns.len()
757    }
758
759    pub fn column_name(&self, idx: usize) -> &str {
760        if idx < self.columns.len() {
761            &self.columns[idx].name
762        } else if idx == self.columns.len() && self.show_mismatch {
763            &self.mismatch.name
764        } else {
765            "Unknown"
766        }
767    }
768
769    /// The name under the active cursor, resolving **hidden** columns too
770    /// (visible region first, then hidden, then Unknown). Lets `toggle` keep a
771    /// logical cursor on a just-hidden column so a second press re-shows it
772    /// (T017: "a second press on the same column re-shows it").
773    pub fn active_column_name(&self) -> &str {
774        let visible = self.columns.len();
775        if self.active_col < visible {
776            &self.columns[self.active_col].name
777        } else {
778            let hidden_idx = self.active_col - visible;
779            if hidden_idx < self.hidden.len() {
780                &self.hidden[hidden_idx].name
781            } else {
782                "Unknown"
783            }
784        }
785    }
786
787    pub fn column_cards(&self, idx: usize) -> &[Card] {
788        if idx < self.columns.len() {
789            &self.columns[idx].cards
790        } else if idx == self.columns.len() && self.show_mismatch {
791            &self.mismatch.cards
792        } else {
793            &[]
794        }
795    }
796
797    pub fn get_selected_card(&self) -> Option<&Card> {
798        self.column_cards(self.active_col).get(self.selected)
799    }
800
801    pub fn move_left(&mut self) {
802        let total = self.real_cols();
803        self.active_col = if total == 0 {
804            0
805        } else if self.active_col >= total {
806            total - 1
807        } else {
808            (self.active_col + total - 1) % total
809        };
810        self.clamp_selection();
811    }
812
813    pub fn move_right(&mut self) {
814        let total = self.real_cols();
815        self.active_col = if total == 0 || self.active_col >= total {
816            0
817        } else {
818            (self.active_col + 1) % total
819        };
820        self.clamp_selection();
821    }
822
823    pub fn move_up(&mut self) {
824        if self.selected > 0 {
825            self.selected -= 1;
826        }
827    }
828
829    pub fn move_down(&mut self) {
830        let cards = self.column_cards(self.active_col);
831        if self.selected + 1 < cards.len() {
832            self.selected += 1;
833        }
834    }
835
836    /// Move the selection by `cards` (negative moves up), clamped to the column
837    /// (tix #1442 follow-up).
838    ///
839    /// The page step: `move_up`/`move_down` are the one-card steps, this is the same
840    /// move by a screenful. It clamps for the reason the detail pane's scroll does — a
841    /// jump past the end should land on the last card, not walk off it.
842    pub fn move_selection_by(&mut self, cards: i32) {
843        let len = self.column_cards(self.active_col).len();
844        if len == 0 {
845            self.selected = 0;
846            return;
847        }
848        let next = i32::try_from(self.selected)
849            .unwrap_or(i32::MAX)
850            .saturating_add(cards);
851        self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
852    }
853
854    pub fn clamp_selection(&mut self) {
855        let cards = self.column_cards(self.active_col);
856        if cards.is_empty() {
857            self.selected = 0;
858        } else if self.selected >= cards.len() {
859            self.selected = cards.len() - 1;
860        }
861    }
862
863    pub fn select_column(&mut self, col: usize) {
864        let total = self.real_cols();
865        if total > 0 {
866            self.active_col = col % total;
867        }
868        self.clamp_selection();
869    }
870
871    fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
872        let sections = Self::group_sections(cards, mode)?;
873        let text = sections
874            .into_iter()
875            .map(|section| {
876                let pct = if section.total == 0 {
877                    0
878                } else {
879                    (section.done * 100) / section.total
880                };
881                format!("{} {}/{} {pct}%", section.name, section.done, section.total)
882            })
883            .collect::<Vec<_>>()
884            .join(" | ");
885        Some(text)
886    }
887
888    fn card_group(card: &Card, key: &str) -> String {
889        match key {
890            "cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
891            "milestone" => card
892                .milestone
893                .clone()
894                .unwrap_or_else(|| "(none)".to_string()),
895            _ => "(none)".to_string(),
896        }
897    }
898
899    fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
900        let key = mode.filter(|m| *m == "cycle" || *m == "milestone")?;
901        if cards.is_empty() {
902            return None;
903        }
904        let mut sections = Vec::new();
905        let mut current = String::new();
906        let mut start = 0usize;
907        let mut done = 0usize;
908        let mut total = 0usize;
909        for (idx, card) in cards.iter().enumerate() {
910            let group = Self::card_group(card, key);
911            if idx == 0 {
912                current = group;
913            } else if group != current {
914                sections.push(GroupSection {
915                    name: std::mem::take(&mut current),
916                    start,
917                    end: idx,
918                    done,
919                    total,
920                });
921                current = group;
922                start = idx;
923                done = 0;
924                total = 0;
925            }
926            total += 1;
927            if card.done {
928                done += 1;
929            }
930        }
931        sections.push(GroupSection {
932            name: current,
933            start,
934            end: cards.len(),
935            done,
936            total,
937        });
938        Some(sections)
939    }
940}
941
942/// Render options carried from the active board configuration into the board
943/// draw. Kept tiny and copyable so tests and call sites stay light.
944#[derive(Debug, Clone, Copy)]
945pub struct BoardDisplay {
946    pub wrap_titles: bool,
947    pub title_lines: usize,
948}
949
950impl Default for BoardDisplay {
951    fn default() -> Self {
952        BoardDisplay {
953            wrap_titles: true,
954            title_lines: 3,
955        }
956    }
957}
958
959impl From<&crate::config::BoardConfiguration> for BoardDisplay {
960    fn from(config: &crate::config::BoardConfiguration) -> Self {
961        BoardDisplay {
962            wrap_titles: config.wrap_titles,
963            title_lines: config.title_lines,
964        }
965    }
966}
967
968/// Equal-width column slices for a board of `total_cols` columns (the same
969/// `Constraint::Ratio` layout `render` draws), shared so hit-testing can map a
970/// pointer position back to a column without re-deriving geometry (D3).
971pub fn compute_column_rects(area: Rect, total_cols: usize) -> Vec<Rect> {
972    if total_cols == 0 {
973        return Vec::new();
974    }
975    Layout::default()
976        .direction(Direction::Horizontal)
977        .constraints(vec![Constraint::Ratio(1, total_cols as u32); total_cols])
978        .split(area)
979        .to_vec()
980}
981
982pub fn render(
983    board: &BoardState,
984    f: &mut Frame,
985    area: Rect,
986    status_msg: &Option<String>,
987    drag_preview_col: Option<usize>,
988    display: BoardDisplay,
989) -> usize {
990    // How many cards one screen of the **active** column holds (tix #1442 follow-up).
991    //
992    // Returned rather than re-derived by the caller: this is the only place that knows
993    // the column's inner height *and* each card's rendered height, and a page key needs
994    // both. `0` when nothing was drawn, so the caller cannot page a column that is not
995    // on screen.
996    let mut page_cards = 0usize;
997
998    if area.height < 2 || area.width < 4 {
999        return 0;
1000    }
1001
1002    let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
1003    let board_height = area.height.saturating_sub(status_height);
1004    let board_area = Rect {
1005        height: board_height,
1006        ..area
1007    };
1008    let status_area = Rect {
1009        y: area.y + board_height,
1010        height: status_height,
1011        ..area
1012    };
1013
1014    let total = board.total_cols();
1015    if total == 0 {
1016        return 0;
1017    }
1018
1019    let chunks = compute_column_rects(board_area, total);
1020
1021    for (i, chunk) in chunks.iter().enumerate() {
1022        let col = if i < board.columns.len() {
1023            &board.columns[i]
1024        } else {
1025            &board.mismatch
1026        };
1027        let group_mode = board
1028            .filter
1029            .as_deref()
1030            .and_then(|q| crate::search::control_token(q, "group"));
1031
1032        let is_active = i == board.active_col;
1033        let is_mismatch_col = i == board.columns.len();
1034        let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
1035
1036        let mut selected_row: Option<usize> = None;
1037        let items: Vec<ListItem> = if let Some(mode) = group_mode.as_deref() {
1038            if let Some(sections) = BoardState::group_sections(&col.cards, Some(mode)) {
1039                let mut items = Vec::new();
1040                for section in sections {
1041                    let pct = if section.total == 0 {
1042                        0
1043                    } else {
1044                        (section.done * 100) / section.total
1045                    };
1046                    let header = format!(
1047                        "-- {mode}: {} ({}/{} done, {pct}%) --",
1048                        section.name, section.done, section.total
1049                    );
1050                    items.push(ListItem::new(Line::from(Span::styled(
1051                        header,
1052                        Style::default()
1053                            .fg(Color::Yellow)
1054                            .add_modifier(Modifier::BOLD),
1055                    ))));
1056                    for j in section.start..section.end {
1057                        let card = &col.cards[j];
1058                        let lines = widgets::card_lines(
1059                            card,
1060                            chunk.width as usize,
1061                            display.wrap_titles,
1062                            display.title_lines,
1063                        );
1064                        let style = if is_active && j == board.selected {
1065                            selected_row = Some(items.len());
1066                            Style::default()
1067                                .bg(Color::DarkGray)
1068                                .add_modifier(Modifier::BOLD)
1069                        } else if board.hover == Some((i, j)) {
1070                            // Hover highlight only — selection is untouched (D8).
1071                            if drag_preview_col == Some(i) {
1072                                Style::default()
1073                                    .fg(Color::Yellow)
1074                                    .add_modifier(Modifier::BOLD)
1075                            } else {
1076                                Style::default().fg(Color::Cyan)
1077                            }
1078                        } else if is_mismatch_highlight {
1079                            Style::default().fg(Color::Red)
1080                        } else {
1081                            Style::default()
1082                        };
1083                        items.push(ListItem::new(lines).style(style));
1084                    }
1085                }
1086                items
1087            } else {
1088                Vec::new()
1089            }
1090        } else {
1091            col.cards
1092                .iter()
1093                .enumerate()
1094                .map(|(j, card)| {
1095                    let lines = widgets::card_lines(
1096                        card,
1097                        chunk.width as usize,
1098                        display.wrap_titles,
1099                        display.title_lines,
1100                    );
1101                    let style = if is_active && j == board.selected {
1102                        selected_row = Some(j);
1103                        Style::default()
1104                            .bg(Color::DarkGray)
1105                            .add_modifier(Modifier::BOLD)
1106                    } else if board.hover == Some((i, j)) {
1107                        if drag_preview_col == Some(i) {
1108                            Style::default()
1109                                .fg(Color::Yellow)
1110                                .add_modifier(Modifier::BOLD)
1111                        } else {
1112                            Style::default().fg(Color::Cyan)
1113                        }
1114                    } else if is_mismatch_highlight {
1115                        Style::default().fg(Color::Red)
1116                    } else {
1117                        Style::default()
1118                    };
1119                    ListItem::new(lines).style(style)
1120                })
1121                .collect()
1122        };
1123
1124        let title = format!("{} ({})", col.name, col.cards.len());
1125        let border_style = if drag_preview_col == Some(i) {
1126            Style::default().fg(Color::Yellow)
1127        } else if is_active {
1128            Style::default().fg(Color::Cyan)
1129        } else if is_mismatch_highlight {
1130            Style::default().fg(Color::Red)
1131        } else {
1132            Style::default()
1133        };
1134
1135        let block = Block::default()
1136            .borders(Borders::ALL)
1137            .title(title)
1138            .border_style(border_style);
1139
1140        let col_chunks = if BoardState::group_footer(&col.cards, group_mode.as_deref()).is_some() {
1141            Layout::default()
1142                .direction(Direction::Vertical)
1143                .constraints([Constraint::Min(1), Constraint::Length(1)])
1144                .split(*chunk)
1145        } else {
1146            Layout::default()
1147                .direction(Direction::Vertical)
1148                .constraints([Constraint::Min(1), Constraint::Length(0)])
1149                .split(*chunk)
1150        };
1151
1152        let list = List::new(items).block(block);
1153
1154        if is_active {
1155            // A page is what the reader can see: the column's inner height against the
1156            // heights the cards actually render at.
1157            page_cards = cards_per_screen(
1158                &col.cards,
1159                chunk.width as usize,
1160                col_chunks[0].height.saturating_sub(2),
1161                display,
1162            );
1163            let mut state = ListState::default();
1164            if let Some(row) = selected_row {
1165                state.select(Some(row));
1166            }
1167            f.render_stateful_widget(list, col_chunks[0], &mut state);
1168        } else {
1169            f.render_widget(list, col_chunks[0]);
1170        }
1171        if let Some(footer) = BoardState::group_footer(&col.cards, group_mode.as_deref()) {
1172            let para = Paragraph::new(Line::from(Span::styled(
1173                footer,
1174                Style::default().fg(Color::DarkGray),
1175            )));
1176            f.render_widget(para, col_chunks[1]);
1177        }
1178    }
1179
1180    if let Some(msg) = status_msg {
1181        // The status area is one row and does not wrap, so anything longer is cut
1182        // by the widget with no indication at all. Mark the cut instead: a
1183        // silently shortened warning reads as a complete one, and the
1184        // retired-`links:` disclosure runs to ~100 characters before the filename,
1185        // which is routinely longer still.
1186        //
1187        // `Paragraph` is handed the truncated string rather than the full one, so
1188        // the ellipsis cannot itself be pushed off the right edge.
1189        //
1190        // Truncation is measured in **display cells**, not scalar values. A CJK
1191        // character occupies two cells, so `chars().take(budget)` yields a string
1192        // up to twice as wide as the area; the widget then hard-cuts it at the
1193        // right edge and discards the marker appended at the end of the
1194        // overflowed string — silently reinstating the exact failure this
1195        // truncation exists to prevent. `unicode-width` is already a dependency,
1196        // so this costs no new one.
1197        let budget = (status_area.width as usize).saturating_sub(1);
1198        let text = truncate_to_cells(msg, budget);
1199        let para =
1200            Paragraph::new(Line::from(Span::raw(text))).style(Style::default().fg(Color::Yellow));
1201        f.render_widget(para, status_area);
1202    }
1203
1204    page_cards
1205}
1206
1207/// How many of `cards` fit into `rows` of a column `width` cells wide.
1208///
1209/// Measured with the same [`widgets::card_lines`] the renderer draws, so the answer is
1210/// the height the reader actually sees rather than an estimate from a constant. Group
1211/// headers and the group footer are **not** counted: the result is a step size for a
1212/// page key, and a step that overshoots by a header or two is friendlier than one that
1213/// undershoots and leaves the reader pressing the key again.
1214///
1215/// Zero for an empty column — "there is nothing to page" — and at least one otherwise,
1216/// so a page key in a column shorter than a single card still moves somewhere.
1217fn cards_per_screen(cards: &[Card], width: usize, rows: u16, display: BoardDisplay) -> usize {
1218    if cards.is_empty() {
1219        return 0;
1220    }
1221    let mut used: u16 = 0;
1222    let mut fits = 0usize;
1223    for card in cards {
1224        let height = u16::try_from(
1225            widgets::card_lines(card, width, display.wrap_titles, display.title_lines).len(),
1226        )
1227        .unwrap_or(u16::MAX);
1228        if fits > 0 && used.saturating_add(height) > rows {
1229            break;
1230        }
1231        used = used.saturating_add(height);
1232        fits += 1;
1233        if used >= rows {
1234            break;
1235        }
1236    }
1237    fits.max(1)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243    use crate::config::Config;
1244
1245    #[test]
1246    fn compute_column_rects_splits_into_equal_columns() {
1247        let area = Rect::new(5, 7, 90, 30);
1248        let rects = compute_column_rects(area, 3);
1249        assert_eq!(rects.len(), 3);
1250        assert_eq!((rects[0].x, rects[0].width), (5, 30));
1251        assert_eq!((rects[1].x, rects[1].width), (35, 30));
1252        assert_eq!((rects[2].x, rects[2].width), (65, 30));
1253        assert_eq!((rects[0].y, rects[0].height), (7, 30));
1254        assert_eq!((rects[2].y, rects[2].height), (7, 30));
1255    }
1256
1257    #[test]
1258    fn compute_column_rects_zero_columns_is_empty() {
1259        assert!(compute_column_rects(Rect::new(0, 0, 10, 10), 0).is_empty());
1260    }
1261
1262    #[test]
1263    fn card_line_count_matches_rendered_card_heights() {
1264        use ratatui::Terminal;
1265        use ratatui::backend::TestBackend;
1266
1267        let wrapped = "A deliberately long card title that cannot fit on a single line inside the narrow column and must wrap over several rows";
1268        let cards = vec![
1269            Card {
1270                number: 1,
1271                title: "Short".into(),
1272                priority: crate::model::Priority::Medium,
1273                labels: vec![],
1274                assignee: None,
1275                filename: "1.md".into(),
1276                commit: None,
1277                created: "2026-01-01".into(),
1278                due_date: None,
1279                status: "todo".into(),
1280                category: None,
1281                category_color: None,
1282                done: false,
1283                cycle: None,
1284                milestone: None,
1285                blocked: None,
1286                children: None,
1287                archived: false,
1288            },
1289            Card {
1290                number: 2,
1291                title: wrapped.into(),
1292                priority: crate::model::Priority::Medium,
1293                labels: vec![],
1294                assignee: None,
1295                filename: "2.md".into(),
1296                commit: None,
1297                created: "2026-01-01".into(),
1298                due_date: None,
1299                status: "todo".into(),
1300                category: None,
1301                category_color: None,
1302                done: false,
1303                cycle: None,
1304                milestone: None,
1305                blocked: None,
1306                children: None,
1307                archived: false,
1308            },
1309            Card {
1310                number: 3,
1311                title: "Another long title card that also wraps".into(),
1312                priority: crate::model::Priority::Medium,
1313                labels: vec![],
1314                assignee: None,
1315                filename: "3.md".into(),
1316                commit: None,
1317                created: "2026-01-01".into(),
1318                due_date: None,
1319                status: "todo".into(),
1320                category: None,
1321                category_color: None,
1322                done: false,
1323                cycle: None,
1324                milestone: None,
1325                blocked: None,
1326                children: None,
1327                archived: false,
1328            },
1329        ];
1330        let board = BoardState {
1331            columns: vec![BoardColumn {
1332                name: "todo".into(),
1333                cards,
1334            }],
1335            hidden: vec![],
1336            mismatch: BoardColumn {
1337                name: "Mismatched".into(),
1338                cards: vec![],
1339            },
1340            active_col: 0,
1341            selected: 0,
1342            filter: None,
1343            hover: None,
1344            show_mismatch: false,
1345        };
1346        let display = BoardDisplay {
1347            wrap_titles: true,
1348            title_lines: 3,
1349        };
1350        let width = 80u16;
1351        let height = 30u16;
1352        let backend = TestBackend::new(width, height);
1353        let mut terminal = Terminal::new(backend).unwrap();
1354        terminal
1355            .draw(|f| {
1356                render(&board, f, f.area(), &None, None, display);
1357            })
1358            .unwrap();
1359        let buffer = terminal.backend().buffer();
1360
1361        let col_width = compute_column_rects(Rect::new(0, 0, width, height), 1)[0].width as usize;
1362
1363        let mut starts: Vec<(u32, u16)> = Vec::new();
1364        for y in 0..height {
1365            let line: String = (1..width.min(24))
1366                .map(|x| buffer[(x, y)].symbol())
1367                .collect();
1368            for card in &board.columns[0].cards {
1369                if line.starts_with(&format!("#{} ", card.number))
1370                    && !starts.iter().any(|(n, _)| *n == card.number)
1371                {
1372                    starts.push((card.number, y));
1373                    break;
1374                }
1375            }
1376        }
1377        starts.sort_by_key(|(_, y)| *y);
1378        assert_eq!(
1379            starts.len(),
1380            3,
1381            "every card found in the buffer: {starts:?}"
1382        );
1383        for (idx, (number, y)) in starts.iter().enumerate() {
1384            let span: u16 = if idx + 1 < starts.len() {
1385                starts[idx + 1].1 - y
1386            } else {
1387                u16::try_from(
1388                    (*y..height)
1389                        .filter(|&rw| {
1390                            rw != height - 1
1391                                && (1..(width - 1)).any(|x| buffer[(x, rw)].symbol() != " ")
1392                        })
1393                        .count(),
1394                )
1395                .unwrap()
1396            };
1397            let card = board.columns[0]
1398                .cards
1399                .iter()
1400                .find(|c| c.number == *number)
1401                .expect("card on the board");
1402            let expected =
1403                widgets::card_line_count(card, col_width, display.wrap_titles, display.title_lines);
1404            assert_eq!(
1405                span, expected,
1406                "rendered height of card #{number} must match card_line_count"
1407            );
1408        }
1409    }
1410
1411    #[test]
1412    fn hit_test_cards_and_column_fallback() {
1413        let tall = "A deliberately long card title that cannot fit on a single line inside the narrow column and wraps over several rows";
1414        let cards = vec![
1415            Card {
1416                number: 1,
1417                title: "one".into(),
1418                priority: Priority::Medium,
1419                labels: vec![],
1420                assignee: None,
1421                filename: "1.md".into(),
1422                commit: None,
1423                created: "2026-01-01".into(),
1424                due_date: None,
1425                status: "todo".into(),
1426                category: None,
1427                category_color: None,
1428                done: false,
1429                cycle: None,
1430                milestone: None,
1431                blocked: None,
1432                children: None,
1433                archived: false,
1434            },
1435            Card {
1436                number: 2,
1437                title: tall.into(),
1438                priority: Priority::Medium,
1439                labels: vec![],
1440                assignee: None,
1441                filename: "2.md".into(),
1442                commit: None,
1443                created: "2026-01-02".into(),
1444                due_date: None,
1445                status: "todo".into(),
1446                category: None,
1447                category_color: None,
1448                done: false,
1449                cycle: None,
1450                milestone: None,
1451                blocked: None,
1452                children: None,
1453                archived: false,
1454            },
1455        ];
1456        let board = BoardState {
1457            columns: vec![
1458                BoardColumn {
1459                    name: "todo".into(),
1460                    cards,
1461                },
1462                BoardColumn {
1463                    name: "done".into(),
1464                    cards: vec![Card {
1465                        number: 3,
1466                        title: "three".into(),
1467                        priority: Priority::Medium,
1468                        labels: vec![],
1469                        assignee: None,
1470                        filename: "3.md".into(),
1471                        commit: None,
1472                        created: "2026-01-03".into(),
1473                        due_date: None,
1474                        status: "done".into(),
1475                        category: None,
1476                        category_color: None,
1477                        done: true,
1478                        cycle: None,
1479                        milestone: None,
1480                        blocked: None,
1481                        children: None,
1482                        archived: false,
1483                    }],
1484                },
1485            ],
1486            hidden: vec![],
1487            mismatch: BoardColumn {
1488                name: "Mismatched".into(),
1489                cards: vec![],
1490            },
1491            active_col: 0,
1492            selected: 0,
1493            filter: None,
1494            hover: None,
1495            show_mismatch: false,
1496        };
1497        let display = BoardDisplay::default();
1498        let area = Rect::new(0, 0, 80, 30);
1499        let widths = compute_column_rects(area, 2);
1500        let col0 = widths[0].width as usize;
1501        let card1_height = widgets::card_line_count(&board.columns[0].cards[0], col0, true, 3);
1502
1503        // First card occupies a single row right below the top border.
1504        assert_eq!(
1505            board.hit_test(area, display, false, 3, 1),
1506            Some(HitTarget::Card { col: 0, row: 0 })
1507        );
1508        // Second (wrapping) card starts where the first one ends, row-wise.
1509        assert_eq!(
1510            board.hit_test(area, display, false, 3, 1 + card1_height),
1511            Some(HitTarget::Card { col: 0, row: 1 })
1512        );
1513        // Blank area below the stacked cards selects the column itself.
1514        assert_eq!(
1515            board.hit_test(area, display, false, 3, 10),
1516            Some(HitTarget::Column { col: 0 })
1517        );
1518        // The block border still resolves to its column.
1519        assert_eq!(
1520            board.hit_test(area, display, false, 0, 5),
1521            Some(HitTarget::Column { col: 0 })
1522        );
1523        // Second column, single card in the first content row.
1524        assert_eq!(
1525            board.hit_test(area, display, false, 55, 0),
1526            Some(HitTarget::Column { col: 1 })
1527        );
1528        assert_eq!(
1529            board.hit_test(area, display, false, 55, 1),
1530            Some(HitTarget::Card { col: 1, row: 0 })
1531        );
1532        // The block's bottom border still resolves to its column.
1533        assert_eq!(
1534            board.hit_test(area, display, false, 3, 29),
1535            Some(HitTarget::Column { col: 0 })
1536        );
1537    }
1538
1539    #[test]
1540    fn hit_test_outside_area_is_none() {
1541        let board = BoardState {
1542            columns: vec![BoardColumn {
1543                name: "todo".into(),
1544                cards: vec![Card {
1545                    number: 1,
1546                    title: "one".into(),
1547                    priority: Priority::Medium,
1548                    labels: vec![],
1549                    assignee: None,
1550                    filename: "1.md".into(),
1551                    commit: None,
1552                    created: "2026-01-01".into(),
1553                    due_date: None,
1554                    status: "todo".into(),
1555                    category: None,
1556                    category_color: None,
1557                    done: false,
1558                    cycle: None,
1559                    milestone: None,
1560                    blocked: None,
1561                    children: None,
1562                    archived: false,
1563                }],
1564            }],
1565            hidden: vec![],
1566            mismatch: BoardColumn {
1567                name: "Mismatched".into(),
1568                cards: vec![],
1569            },
1570            active_col: 0,
1571            selected: 0,
1572            filter: None,
1573            hover: None,
1574            show_mismatch: false,
1575        };
1576        let display = BoardDisplay::default();
1577        let area = Rect::new(0, 5, 40, 12);
1578        // Above the area.
1579        assert_eq!(board.hit_test(area, display, false, 3, 3), None);
1580        // Inside the area on a card (content starts one row below the top border).
1581        assert_eq!(
1582            board.hit_test(area, display, false, 3, 6),
1583            Some(HitTarget::Card { col: 0, row: 0 })
1584        );
1585        // Beyond the right edge of the area.
1586        assert_eq!(board.hit_test(area, display, false, 44, 7), None);
1587    }
1588
1589    #[test]
1590    fn hit_test_respects_status_row() {
1591        let board = BoardState {
1592            columns: vec![BoardColumn {
1593                name: "todo".into(),
1594                cards: vec![Card {
1595                    number: 1,
1596                    title: "one".into(),
1597                    priority: Priority::Medium,
1598                    labels: vec![],
1599                    assignee: None,
1600                    filename: "1.md".into(),
1601                    commit: None,
1602                    created: "2026-01-01".into(),
1603                    due_date: None,
1604                    status: "todo".into(),
1605                    category: None,
1606                    category_color: None,
1607                    done: false,
1608                    cycle: None,
1609                    milestone: None,
1610                    blocked: None,
1611                    children: None,
1612                    archived: false,
1613                }],
1614            }],
1615            hidden: vec![],
1616            mismatch: BoardColumn {
1617                name: "Mismatched".into(),
1618                cards: vec![],
1619            },
1620            active_col: 0,
1621            selected: 0,
1622            filter: None,
1623            hover: None,
1624            show_mismatch: false,
1625        };
1626        let display = BoardDisplay::default();
1627        let area = Rect::new(0, 0, 80, 30);
1628        // With a status message the last row belongs to the status line, not a
1629        // column, so it must not resolve.
1630        assert_eq!(board.hit_test(area, display, true, 3, 29), None);
1631        assert_eq!(
1632            board.hit_test(area, display, true, 3, 1),
1633            Some(HitTarget::Card { col: 0, row: 0 })
1634        );
1635    }
1636
1637    #[test]
1638    fn hit_test_group_header_is_not_a_card() {
1639        let cycle_card = |number: u32, cycle: &str| Card {
1640            number,
1641            title: format!("task {number}"),
1642            priority: Priority::Medium,
1643            labels: vec![],
1644            assignee: None,
1645            filename: format!("{number}.md"),
1646            commit: None,
1647            created: format!("2026-01-{number:02}"),
1648            due_date: None,
1649            status: "todo".into(),
1650            category: None,
1651            category_color: None,
1652            done: false,
1653            cycle: Some(cycle.into()),
1654            milestone: None,
1655            blocked: None,
1656            children: None,
1657            archived: false,
1658        };
1659        let board = BoardState {
1660            columns: vec![
1661                BoardColumn {
1662                    name: "todo".into(),
1663                    cards: vec![cycle_card(1, "alpha"), cycle_card(2, "beta")],
1664                },
1665                BoardColumn {
1666                    name: "done".into(),
1667                    cards: vec![],
1668                },
1669            ],
1670            hidden: vec![],
1671            mismatch: BoardColumn {
1672                name: "Mismatched".into(),
1673                cards: vec![],
1674            },
1675            active_col: 0,
1676            selected: 0,
1677            filter: Some("group:cycle".into()),
1678            hover: None,
1679            show_mismatch: false,
1680        };
1681        let display = BoardDisplay::default();
1682        let area = Rect::new(0, 0, 80, 30);
1683        let col0 = area.width / 2;
1684        let first_height =
1685            widgets::card_line_count(&board.columns[0].cards[0], col0 as usize, true, 3);
1686
1687        // Header row for the "alpha" section selects the column, not a card.
1688        assert_eq!(
1689            board.hit_test(area, display, false, 5, 1),
1690            Some(HitTarget::Column { col: 0 })
1691        );
1692        // The card below its header maps to its real column index.
1693        assert_eq!(
1694            board.hit_test(area, display, false, 5, 2),
1695            Some(HitTarget::Card { col: 0, row: 0 })
1696        );
1697        // Second section header sits below the first card's rows.
1698        assert_eq!(
1699            board.hit_test(area, display, false, 5, 2 + first_height),
1700            Some(HitTarget::Column { col: 0 })
1701        );
1702        assert_eq!(
1703            board.hit_test(area, display, false, 5, 3 + first_height),
1704            Some(HitTarget::Card { col: 0, row: 1 })
1705        );
1706    }
1707
1708    #[test]
1709    fn hit_test_mismatch_column_resolves() {
1710        let board = BoardState {
1711            columns: vec![BoardColumn {
1712                name: "todo".into(),
1713                cards: vec![],
1714            }],
1715            hidden: vec![],
1716            mismatch: BoardColumn {
1717                name: "Mismatched".into(),
1718                cards: vec![Card {
1719                    number: 9,
1720                    title: "oddball".into(),
1721                    priority: Priority::Medium,
1722                    labels: vec![],
1723                    assignee: None,
1724                    filename: "9.md".into(),
1725                    commit: None,
1726                    created: "2026-01-09".into(),
1727                    due_date: None,
1728                    status: "weird".into(),
1729                    category: None,
1730                    category_color: None,
1731                    done: false,
1732                    cycle: None,
1733                    milestone: None,
1734                    blocked: None,
1735                    children: None,
1736                    archived: false,
1737                }],
1738            },
1739            active_col: 0,
1740            selected: 0,
1741            filter: None,
1742            hover: None,
1743            show_mismatch: true,
1744        };
1745        let display = BoardDisplay::default();
1746        let area = Rect::new(0, 0, 90, 30);
1747        let mismatch_col = board.columns.len();
1748        // The mismatch column is the last (3rd) of the equal-width columns.
1749        assert_eq!(
1750            board.hit_test(area, display, false, 70, 1),
1751            Some(HitTarget::Card {
1752                col: mismatch_col,
1753                row: 0
1754            })
1755        );
1756    }
1757
1758    #[test]
1759    fn render_highlights_hovered_card_without_selecting() {
1760        use ratatui::Terminal;
1761        use ratatui::backend::TestBackend;
1762        let board = BoardState {
1763            columns: vec![BoardColumn {
1764                name: "todo".into(),
1765                cards: vec![
1766                    Card {
1767                        number: 1,
1768                        title: "Alpha".into(),
1769                        priority: Priority::Medium,
1770                        labels: vec![],
1771                        assignee: None,
1772                        filename: "1.md".into(),
1773                        commit: None,
1774                        created: "2026-01-01".into(),
1775                        due_date: None,
1776                        status: "todo".into(),
1777                        category: None,
1778                        category_color: None,
1779                        done: false,
1780                        cycle: None,
1781                        milestone: None,
1782                        blocked: None,
1783                        children: None,
1784                        archived: false,
1785                    },
1786                    Card {
1787                        number: 2,
1788                        title: "Beta".into(),
1789                        priority: Priority::Medium,
1790                        labels: vec![],
1791                        assignee: None,
1792                        filename: "2.md".into(),
1793                        commit: None,
1794                        created: "2026-01-02".into(),
1795                        due_date: None,
1796                        status: "todo".into(),
1797                        category: None,
1798                        category_color: None,
1799                        done: false,
1800                        cycle: None,
1801                        milestone: None,
1802                        blocked: None,
1803                        children: None,
1804                        archived: false,
1805                    },
1806                ],
1807            }],
1808            hidden: vec![],
1809            mismatch: BoardColumn {
1810                name: "Mismatched".into(),
1811                cards: vec![],
1812            },
1813            show_mismatch: false,
1814            active_col: 0,
1815            selected: 0,
1816            hover: Some((0, 1)),
1817            filter: None,
1818        };
1819        let display = BoardDisplay::default();
1820        let backend = TestBackend::new(80, 30);
1821        let mut terminal = Terminal::new(backend).unwrap();
1822        terminal
1823            .draw(|f| {
1824                render(&board, f, f.area(), &None, None, display);
1825            })
1826            .unwrap();
1827        let buffer = terminal.backend().buffer();
1828
1829        let mut rows = [None, None];
1830        for y in 0..30u16 {
1831            let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
1832            if line.starts_with("#1") {
1833                rows[0] = Some(y);
1834            }
1835            if line.starts_with("#2") {
1836                rows[1] = Some(y);
1837            }
1838        }
1839        let y1 = rows[0].expect("first card rendered");
1840        let y2 = rows[1].expect("second card rendered");
1841
1842        // The selected card keeps its selection style (dark-gray background)...
1843        assert_eq!(buffer[(3, y1)].style().bg, Some(Color::DarkGray));
1844        // ...while the hovered card is highlighted in cyan...
1845        assert_eq!(buffer[(3, y2)].style().fg, Some(Color::Cyan));
1846        // ...and hovering changed nothing about the selection.
1847        assert_eq!((board.active_col, board.selected), (0, 0));
1848    }
1849
1850    #[test]
1851    fn total_cols_includes_mismatch() {
1852        let board = BoardState {
1853            columns: vec![
1854                BoardColumn {
1855                    name: "a".into(),
1856                    cards: vec![],
1857                },
1858                BoardColumn {
1859                    name: "b".into(),
1860                    cards: vec![],
1861                },
1862            ],
1863            mismatch: BoardColumn {
1864                name: "Mismatched".into(),
1865                cards: vec![],
1866            },
1867            active_col: 0,
1868            selected: 0,
1869            filter: None,
1870            hover: None,
1871            hidden: vec![],
1872            show_mismatch: true,
1873        };
1874        assert_eq!(board.total_cols(), 3);
1875    }
1876
1877    #[test]
1878    fn column_name_mismatch_index() {
1879        let board = BoardState {
1880            columns: vec![BoardColumn {
1881                name: "todo".into(),
1882                cards: vec![],
1883            }],
1884            mismatch: BoardColumn {
1885                name: "Mismatched".into(),
1886                cards: vec![],
1887            },
1888            active_col: 0,
1889            selected: 0,
1890            filter: None,
1891            hover: None,
1892            hidden: vec![],
1893            show_mismatch: true,
1894        };
1895        assert_eq!(board.column_name(0), "todo");
1896        assert_eq!(board.column_name(1), "Mismatched");
1897    }
1898
1899    #[test]
1900    fn move_left_wraps_around() {
1901        let mut board = BoardState {
1902            columns: vec![
1903                BoardColumn {
1904                    name: "a".into(),
1905                    cards: vec![],
1906                },
1907                BoardColumn {
1908                    name: "b".into(),
1909                    cards: vec![],
1910                },
1911            ],
1912            hidden: vec![],
1913            mismatch: BoardColumn {
1914                name: "Mismatched".into(),
1915                cards: vec![],
1916            },
1917            active_col: 0,
1918            selected: 0,
1919            filter: None,
1920            hover: None,
1921            show_mismatch: true,
1922        };
1923        board.move_left();
1924        assert_eq!(board.active_col, 1);
1925    }
1926
1927    #[test]
1928    fn move_right_wraps_around() {
1929        let mut board = BoardState {
1930            columns: vec![
1931                BoardColumn {
1932                    name: "a".into(),
1933                    cards: vec![],
1934                },
1935                BoardColumn {
1936                    name: "b".into(),
1937                    cards: vec![],
1938                },
1939            ],
1940            mismatch: BoardColumn {
1941                name: "Mismatched".into(),
1942                cards: vec![],
1943            },
1944            active_col: 2,
1945            selected: 0,
1946            filter: None,
1947            hover: None,
1948            hidden: vec![],
1949            show_mismatch: true,
1950        };
1951        board.move_right();
1952        assert_eq!(board.active_col, 0);
1953    }
1954
1955    #[test]
1956    fn clamp_selection_empty_column() {
1957        let mut board = BoardState {
1958            columns: vec![BoardColumn {
1959                name: "a".into(),
1960                cards: vec![],
1961            }],
1962            mismatch: BoardColumn {
1963                name: "Mismatched".into(),
1964                cards: vec![],
1965            },
1966            active_col: 0,
1967            selected: 5,
1968            filter: None,
1969            hover: None,
1970            hidden: vec![],
1971            show_mismatch: true,
1972        };
1973        board.clamp_selection();
1974        assert_eq!(board.selected, 0);
1975    }
1976
1977    #[test]
1978    fn clamp_selection_single_card() {
1979        let mut board = BoardState {
1980            columns: vec![BoardColumn {
1981                name: "a".into(),
1982                cards: vec![Card {
1983                    number: 1,
1984                    title: "X".into(),
1985                    priority: crate::model::Priority::Medium,
1986                    labels: vec![],
1987                    assignee: None,
1988                    filename: "x.md".into(),
1989                    commit: None,
1990                    created: "2026-01-01".into(),
1991                    due_date: None,
1992                    status: "todo".into(),
1993                    category: None,
1994                    category_color: None,
1995                    done: false,
1996                    cycle: None,
1997                    milestone: None,
1998                    blocked: None,
1999                    children: None,
2000                    archived: false,
2001                }],
2002            }],
2003            mismatch: BoardColumn {
2004                name: "Mismatched".into(),
2005                cards: vec![],
2006            },
2007            active_col: 0,
2008            selected: 5,
2009            filter: None,
2010            hover: None,
2011            hidden: vec![],
2012            show_mismatch: true,
2013        };
2014        board.clamp_selection();
2015        assert_eq!(board.selected, 0);
2016    }
2017
2018    #[test]
2019    fn move_up_clamps_at_top() {
2020        let mut board = BoardState {
2021            columns: vec![BoardColumn {
2022                name: "a".into(),
2023                cards: vec![Card {
2024                    number: 1,
2025                    title: "X".into(),
2026                    priority: crate::model::Priority::Medium,
2027                    labels: vec![],
2028                    assignee: None,
2029                    filename: "x.md".into(),
2030                    commit: None,
2031                    created: "2026-01-01".into(),
2032                    due_date: None,
2033                    status: "todo".into(),
2034                    category: None,
2035                    category_color: None,
2036                    done: false,
2037                    cycle: None,
2038                    milestone: None,
2039                    blocked: None,
2040                    children: None,
2041                    archived: false,
2042                }],
2043            }],
2044            mismatch: BoardColumn {
2045                name: "Mismatched".into(),
2046                cards: vec![],
2047            },
2048            active_col: 0,
2049            selected: 0,
2050            filter: None,
2051            hover: None,
2052            hidden: vec![],
2053            show_mismatch: true,
2054        };
2055        board.move_up();
2056        assert_eq!(board.selected, 0);
2057    }
2058
2059    #[test]
2060    fn move_down_clamps_at_bottom() {
2061        let mut board = BoardState {
2062            columns: vec![BoardColumn {
2063                name: "a".into(),
2064                cards: vec![Card {
2065                    number: 1,
2066                    title: "X".into(),
2067                    priority: crate::model::Priority::Medium,
2068                    labels: vec![],
2069                    assignee: None,
2070                    filename: "x.md".into(),
2071                    commit: None,
2072                    created: "2026-01-01".into(),
2073                    due_date: None,
2074                    status: "todo".into(),
2075                    category: None,
2076                    category_color: None,
2077                    done: false,
2078                    cycle: None,
2079                    milestone: None,
2080                    blocked: None,
2081                    children: None,
2082                    archived: false,
2083                }],
2084            }],
2085            mismatch: BoardColumn {
2086                name: "Mismatched".into(),
2087                cards: vec![],
2088            },
2089            active_col: 0,
2090            selected: 0,
2091            filter: None,
2092            hover: None,
2093            hidden: vec![],
2094            show_mismatch: true,
2095        };
2096        board.move_down();
2097        assert_eq!(board.selected, 0);
2098    }
2099
2100    #[test]
2101    fn render_smoke_does_not_panic_on_tiny_frame() {
2102        use ratatui::Terminal;
2103        use ratatui::backend::TestBackend;
2104
2105        let backend = TestBackend::new(5, 3);
2106        let mut terminal = Terminal::new(backend).unwrap();
2107        let board = BoardState {
2108            columns: vec![],
2109            mismatch: BoardColumn {
2110                name: "Mismatched".into(),
2111                cards: vec![],
2112            },
2113            active_col: 0,
2114            selected: 0,
2115            filter: None,
2116            hover: None,
2117            hidden: vec![],
2118            show_mismatch: true,
2119        };
2120        terminal
2121            .draw(|f| {
2122                render(&board, f, f.area(), &None, None, BoardDisplay::default());
2123            })
2124            .unwrap();
2125    }
2126
2127    #[test]
2128    fn render_smoke_full_board() {
2129        use ratatui::Terminal;
2130        use ratatui::backend::TestBackend;
2131
2132        let backend = TestBackend::new(80, 24);
2133        let mut terminal = Terminal::new(backend).unwrap();
2134        let board = BoardState {
2135            columns: vec![
2136                BoardColumn {
2137                    name: "todo".into(),
2138                    cards: vec![],
2139                },
2140                BoardColumn {
2141                    name: "done".into(),
2142                    cards: vec![],
2143                },
2144            ],
2145            mismatch: BoardColumn {
2146                name: "Mismatched".into(),
2147                cards: vec![],
2148            },
2149            active_col: 0,
2150            selected: 0,
2151            filter: None,
2152            hover: None,
2153            hidden: vec![],
2154            show_mismatch: true,
2155        };
2156        terminal
2157            .draw(|f| {
2158                render(
2159                    &board,
2160                    f,
2161                    f.area(),
2162                    &Some("test msg".into()),
2163                    None,
2164                    BoardDisplay::default(),
2165                );
2166            })
2167            .unwrap();
2168    }
2169
2170    #[test]
2171    fn render_selected_multiline_card_highlights_all_wrapped_lines() {
2172        use ratatui::Terminal;
2173        use ratatui::backend::TestBackend;
2174
2175        let title = "This is a deliberately long card title that will not fit on a single line inside the column and must wrap more than once";
2176        let card = crate::ui::board::Card {
2177            number: 7,
2178            title: title.into(),
2179            priority: crate::model::Priority::Medium,
2180            labels: vec![],
2181            assignee: None,
2182            filename: "x.md".into(),
2183            commit: None,
2184            created: "2026-01-01".into(),
2185            due_date: None,
2186            status: "todo".into(),
2187            category: None,
2188            category_color: None,
2189            done: false,
2190            cycle: None,
2191            milestone: None,
2192            blocked: None,
2193            children: None,
2194            archived: false,
2195        };
2196        let backend = TestBackend::new(80, 24);
2197        let mut terminal = Terminal::new(backend).unwrap();
2198        let board = BoardState {
2199            columns: vec![BoardColumn {
2200                name: "todo".into(),
2201                cards: vec![card],
2202            }],
2203            mismatch: BoardColumn {
2204                name: "Mismatched".into(),
2205                cards: vec![],
2206            },
2207            active_col: 0,
2208            selected: 0,
2209            filter: None,
2210            hover: None,
2211            hidden: vec![],
2212            show_mismatch: false,
2213        };
2214        let display = BoardDisplay {
2215            wrap_titles: true,
2216            title_lines: 3,
2217        };
2218        terminal
2219            .draw(|f| {
2220                render(&board, f, f.area(), &None, None, display);
2221            })
2222            .unwrap();
2223        let buffer = terminal.backend().buffer();
2224
2225        // The card is the only item in the only column; its block top border
2226        // sits on y=0, so the ListItem starts at y=1 and each wrapped line
2227        // must carry the selected-card highlight (DarkGray bg + BOLD).
2228        let mut highlighted = 0;
2229        for y in 1..buffer.area().height - 1 {
2230            let cell = &buffer[(2, y)];
2231            if cell.bg != Color::DarkGray {
2232                break;
2233            }
2234            assert!(
2235                cell.modifier.contains(Modifier::BOLD),
2236                "highlighted continuation line must stay bold: {cell:?}"
2237            );
2238            highlighted += 1;
2239        }
2240        assert!(
2241            highlighted >= 2,
2242            "wrapped card highlights every line, got {highlighted}"
2243        );
2244        assert!(
2245            highlighted <= display.title_lines,
2246            "highlight never exceeds the line limit: {highlighted}"
2247        );
2248    }
2249
2250    #[test]
2251    fn render_grouped_mode_shows_section_headers() {
2252        use ratatui::Terminal;
2253        use ratatui::backend::TestBackend;
2254
2255        let backend = TestBackend::new(80, 24);
2256        let mut terminal = Terminal::new(backend).unwrap();
2257        let board = BoardState {
2258            columns: vec![BoardColumn {
2259                name: "todo".into(),
2260                cards: vec![
2261                    Card {
2262                        number: 1,
2263                        title: "A".into(),
2264                        priority: crate::model::Priority::Medium,
2265                        labels: vec![],
2266                        assignee: None,
2267                        filename: "a.md".into(),
2268                        commit: None,
2269                        created: "2026-01-01".into(),
2270                        due_date: None,
2271                        status: "todo".into(),
2272                        category: None,
2273                        category_color: None,
2274                        done: false,
2275                        cycle: Some("Cycle 1".into()),
2276                        milestone: None,
2277                        blocked: None,
2278                        children: None,
2279                        archived: false,
2280                    },
2281                    Card {
2282                        number: 2,
2283                        title: "B".into(),
2284                        priority: crate::model::Priority::Medium,
2285                        labels: vec![],
2286                        assignee: None,
2287                        filename: "b.md".into(),
2288                        commit: None,
2289                        created: "2026-01-01".into(),
2290                        due_date: None,
2291                        status: "todo".into(),
2292                        category: None,
2293                        category_color: None,
2294                        done: true,
2295                        cycle: Some("Cycle 1".into()),
2296                        milestone: None,
2297                        blocked: None,
2298                        children: None,
2299                        archived: false,
2300                    },
2301                    Card {
2302                        number: 3,
2303                        title: "C".into(),
2304                        priority: crate::model::Priority::Medium,
2305                        labels: vec![],
2306                        assignee: None,
2307                        filename: "c.md".into(),
2308                        commit: None,
2309                        created: "2026-01-01".into(),
2310                        due_date: None,
2311                        status: "todo".into(),
2312                        category: None,
2313                        category_color: None,
2314                        done: false,
2315                        cycle: Some("Cycle 2".into()),
2316                        milestone: None,
2317                        blocked: None,
2318                        children: None,
2319                        archived: false,
2320                    },
2321                ],
2322            }],
2323            mismatch: BoardColumn {
2324                name: "Mismatched".into(),
2325                cards: vec![],
2326            },
2327            active_col: 0,
2328            selected: 0,
2329            filter: Some("group:cycle".into()),
2330            hover: None,
2331            hidden: vec![],
2332            show_mismatch: false,
2333        };
2334        terminal
2335            .draw(|f| {
2336                render(&board, f, f.area(), &None, None, BoardDisplay::default());
2337            })
2338            .unwrap();
2339
2340        let buffer = terminal.backend().buffer();
2341        let mut lines = Vec::new();
2342        for y in 0..buffer.area.height {
2343            let line = (0..buffer.area.width)
2344                .map(|x| buffer[(x, y)].symbol())
2345                .collect::<String>();
2346            lines.push(line);
2347        }
2348        let rendered = lines.join("\n");
2349        assert!(rendered.contains("-- cycle: Cycle 1 (1/2 done, 50%) --"));
2350        assert!(rendered.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"));
2351    }
2352
2353    #[test]
2354    fn build_filtered_keeps_only_fuzzy_matches() {
2355        use crate::model::Issue;
2356        use crate::search::Corpus;
2357
2358        let to_issue = |number: u32, title: &str| Issue {
2359            number,
2360            title: title.to_string(),
2361            status: crate::model::Status::new("todo"),
2362            labels: vec![],
2363            priority: crate::model::Priority::Medium,
2364            assignee: None,
2365            estimate: None,
2366            commit: None,
2367            gitlab_id: None,
2368            linear_id: None,
2369            mission: None,
2370            task: None,
2371            relationships: vec![],
2372            files: vec![],
2373            cycles: None,
2374            milestones: None,
2375            reviewer: None,
2376            start_date: None,
2377            due_date: None,
2378            created: "2026-01-01".into(),
2379            updated: "2026-01-01".into(),
2380            filename: format!("2026-01-01-{number}.md"),
2381            body: String::new(),
2382            history: crate::model::History::new(),
2383            has_retired_links_key: false,
2384            parent: None,
2385            extra_frontmatter: std::collections::BTreeMap::new(),
2386            archived: false,
2387            entered_at: None,
2388            source: None,
2389        };
2390        let corpus = Corpus {
2391            issues: vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
2392            texts: vec!["Fix login bug".to_string(), "Polish exports".to_string()],
2393        };
2394        let config = Config::default();
2395        let (_, resolution) = crate::workflow::active_resolution(&config);
2396        let board_config = config.default_config();
2397
2398        let board = BoardState::build_filtered(
2399            board_config,
2400            &config,
2401            &corpus,
2402            "log",
2403            &StructuredFilter::default(),
2404            &resolution,
2405        );
2406        assert_eq!(board.columns.len(), board_config.columns.len());
2407        assert_eq!(board.column_cards(0).len(), 1);
2408        assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
2409        assert_eq!(board.filter.as_deref(), Some("log"));
2410
2411        let none = BoardState::build_filtered(
2412            board_config,
2413            &config,
2414            &corpus,
2415            "zzw",
2416            &StructuredFilter::default(),
2417            &resolution,
2418        );
2419        assert!(none.is_empty());
2420
2421        let empty = BoardState::build_filtered(
2422            board_config,
2423            &config,
2424            &corpus,
2425            "",
2426            &StructuredFilter::default(),
2427            &resolution,
2428        );
2429        assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
2430        assert!(empty.filter.is_some());
2431    }
2432
2433    #[test]
2434    fn build_filtered_group_cycle_keeps_original_titles() {
2435        use crate::model::Issue;
2436        use crate::search::Corpus;
2437
2438        let issue = |number: u32, title: &str, status: &str, cycles: &[&str]| Issue {
2439            number,
2440            title: title.to_string(),
2441            status: crate::model::Status::new(status),
2442            labels: vec![],
2443            priority: crate::model::Priority::Medium,
2444            assignee: None,
2445            estimate: None,
2446            commit: None,
2447            gitlab_id: None,
2448            linear_id: None,
2449            mission: None,
2450            task: None,
2451            relationships: vec![],
2452            files: vec![],
2453            cycles: Some(cycles.iter().map(|c| (*c).to_string()).collect()),
2454            milestones: None,
2455            reviewer: None,
2456            start_date: None,
2457            due_date: None,
2458            created: "2026-01-01".into(),
2459            updated: "2026-01-01".into(),
2460            filename: format!("2026-01-01-{number}.md"),
2461            body: String::new(),
2462            history: crate::model::History::new(),
2463            has_retired_links_key: false,
2464            parent: None,
2465            extra_frontmatter: std::collections::BTreeMap::new(),
2466            archived: false,
2467            entered_at: None,
2468            source: None,
2469        };
2470
2471        let corpus = Corpus {
2472            issues: vec![
2473                issue(1, "A", "todo", &["Cycle 1"]),
2474                issue(2, "B", "done", &["Cycle 1"]),
2475                issue(3, "C", "todo", &["Cycle 2"]),
2476            ],
2477            texts: vec![
2478                "cycle: Cycle 1\nstatus: todo".to_string(),
2479                "cycle: Cycle 1\nstatus: done".to_string(),
2480                "cycle: Cycle 2\nstatus: todo".to_string(),
2481            ],
2482        };
2483
2484        let config = Config::default();
2485        let (_, resolution) = crate::workflow::active_resolution(&config);
2486        let board = BoardState::build_filtered(
2487            config.default_config(),
2488            &config,
2489            &corpus,
2490            "group:cycle",
2491            &StructuredFilter::default(),
2492            &resolution,
2493        );
2494        let titles: Vec<String> = board
2495            .columns
2496            .iter()
2497            .flat_map(|col| col.cards.iter().map(|c| c.title.clone()))
2498            .collect();
2499        assert!(titles.iter().any(|t| t == "A"));
2500        assert!(titles.iter().any(|t| t == "B"));
2501        assert!(titles.iter().any(|t| t == "C"));
2502    }
2503
2504    #[test]
2505    fn group_footer_reports_done_progress() {
2506        let cards = vec![
2507            Card {
2508                number: 1,
2509                title: "A".into(),
2510                priority: crate::model::Priority::Medium,
2511                labels: vec![],
2512                assignee: None,
2513                filename: "a.md".into(),
2514                commit: None,
2515                created: "2026-01-01".into(),
2516                due_date: None,
2517                status: "todo".into(),
2518                category: None,
2519                category_color: None,
2520                done: false,
2521                cycle: Some("Cycle 1".into()),
2522                milestone: None,
2523                blocked: None,
2524                children: None,
2525                archived: false,
2526            },
2527            Card {
2528                number: 2,
2529                title: "B".into(),
2530                priority: crate::model::Priority::Medium,
2531                labels: vec![],
2532                assignee: None,
2533                filename: "b.md".into(),
2534                commit: None,
2535                created: "2026-01-01".into(),
2536                due_date: None,
2537                status: "done".into(),
2538                category: None,
2539                category_color: None,
2540                done: true,
2541                cycle: Some("Cycle 1".into()),
2542                milestone: None,
2543                blocked: None,
2544                children: None,
2545                archived: false,
2546            },
2547        ];
2548        let text = BoardState::group_footer(&cards, Some("cycle")).unwrap();
2549        assert!(text.contains("Cycle 1 1/2 50%"));
2550    }
2551
2552    #[test]
2553    fn build_filtered_bare_number_matches_exact_issue_only() {
2554        use crate::model::Issue;
2555        use crate::search::Corpus;
2556
2557        let to_issue = |number: u32, title: &str, status: &str| Issue {
2558            number,
2559            title: title.to_string(),
2560            status: crate::model::Status::new(status),
2561            labels: vec![],
2562            priority: crate::model::Priority::Medium,
2563            assignee: None,
2564            estimate: None,
2565            commit: None,
2566            gitlab_id: None,
2567            linear_id: None,
2568            mission: None,
2569            task: None,
2570            relationships: vec![],
2571            files: vec![],
2572            cycles: None,
2573            milestones: None,
2574            reviewer: None,
2575            start_date: None,
2576            due_date: None,
2577            created: "2026-01-01".into(),
2578            updated: "2026-01-01".into(),
2579            filename: format!("2026-01-01-{number}.md"),
2580            body: String::new(),
2581            history: crate::model::History::new(),
2582            has_retired_links_key: false,
2583            parent: None,
2584            extra_frontmatter: std::collections::BTreeMap::new(),
2585            archived: false,
2586            entered_at: None,
2587            source: None,
2588        };
2589        let corpus = Corpus {
2590            issues: vec![
2591                to_issue(55, "Concrete target", "todo"),
2592                to_issue(155, "Digit-rich title", "todo"),
2593                to_issue(3, "Login bug", "todo"),
2594            ],
2595            texts: vec![
2596                "Concrete target".to_string(),
2597                "Digit-rich title".to_string(),
2598                "Login bug".to_string(),
2599            ],
2600        };
2601        let config = Config::default();
2602        let (_, resolution) = crate::workflow::active_resolution(&config);
2603        let board_config = config.default_config();
2604
2605        let exact = BoardState::build_filtered(
2606            board_config,
2607            &config,
2608            &corpus,
2609            "55",
2610            &StructuredFilter::default(),
2611            &resolution,
2612        );
2613        assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
2614        assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
2615        assert_eq!(
2616            exact.column_cards(0)[0].number,
2617            55,
2618            "issue 155's digit sequence must not qualify"
2619        );
2620        assert_eq!(exact.filter.as_deref(), Some("55"));
2621
2622        // Number miss -> empty board.
2623        let miss = BoardState::build_filtered(
2624            board_config,
2625            &config,
2626            &corpus,
2627            "999",
2628            &StructuredFilter::default(),
2629            &resolution,
2630        );
2631        assert!(miss.is_empty(), "no issue 999 => zero matches");
2632
2633        // Non-numeric query keeps fuzzy behavior (login finds issue 3 only).
2634        let fuzzy = BoardState::build_filtered(
2635            board_config,
2636            &config,
2637            &corpus,
2638            "login",
2639            &StructuredFilter::default(),
2640            &resolution,
2641        );
2642        assert_eq!(fuzzy.column_cards(0).len(), 1);
2643        assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
2644    }
2645
2646    #[test]
2647    fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
2648        use crate::model::Issue;
2649        use crate::search::Corpus;
2650
2651        let to_issue = |number: u32, title: &str| Issue {
2652            number,
2653            title: title.to_string(),
2654            status: crate::model::Status::new("todo"),
2655            labels: vec![],
2656            priority: crate::model::Priority::Medium,
2657            assignee: None,
2658            estimate: None,
2659            commit: None,
2660            gitlab_id: None,
2661            linear_id: None,
2662            mission: None,
2663            task: None,
2664            relationships: vec![],
2665            files: vec![],
2666            cycles: None,
2667            milestones: None,
2668            reviewer: None,
2669            start_date: None,
2670            due_date: None,
2671            created: "2026-01-01".into(),
2672            updated: "2026-01-01".into(),
2673            filename: format!("2026-01-01-{number}.md"),
2674            body: String::new(),
2675            history: crate::model::History::new(),
2676            has_retired_links_key: false,
2677            parent: None,
2678            extra_frontmatter: std::collections::BTreeMap::new(),
2679            archived: false,
2680            entered_at: None,
2681            source: None,
2682        };
2683        let corpus = Corpus {
2684            issues: vec![
2685                to_issue(55, "Concrete target"),
2686                to_issue(155, "Digit-rich title"),
2687            ],
2688            texts: vec![
2689                "Concrete target".to_string(),
2690                "Digit-rich title".to_string(),
2691            ],
2692        };
2693        let config = Config::default();
2694        let (_, resolution) = crate::workflow::active_resolution(&config);
2695        let board_config = config.default_config();
2696
2697        // "55bug" is not a bare number: fuzzy path applies (no such text, so
2698        // zero matches rather than exact issue 55).
2699        let mixed = BoardState::build_filtered(
2700            board_config,
2701            &config,
2702            &corpus,
2703            "55bug",
2704            &StructuredFilter::default(),
2705            &resolution,
2706        );
2707        assert!(mixed.is_empty(), "mixed query is not an exact-number query");
2708    }
2709
2710    #[test]
2711    fn build_filtered_number_composes_with_structured_filter_and() {
2712        use crate::model::Issue;
2713        use crate::search::Corpus;
2714
2715        let cases = [
2716            (55u32, "Issue 55 todo", "todo"),
2717            (156u32, "Issue 156 todo", "todo"),
2718            (57u32, "Issue 57 done", "done"),
2719        ];
2720        let corpus = Corpus {
2721            issues: cases
2722                .iter()
2723                .map(|&(number, title, status)| Issue {
2724                    number,
2725                    title: title.to_string(),
2726                    status: crate::model::Status::new(status),
2727                    labels: vec![],
2728                    priority: crate::model::Priority::Medium,
2729                    assignee: None,
2730                    estimate: None,
2731                    commit: None,
2732                    gitlab_id: None,
2733                    linear_id: None,
2734                    mission: None,
2735                    task: None,
2736                    relationships: vec![],
2737                    files: vec![],
2738                    cycles: None,
2739                    milestones: None,
2740                    reviewer: None,
2741                    start_date: None,
2742                    due_date: None,
2743                    created: "2026-01-01".into(),
2744                    updated: "2026-01-01".into(),
2745                    filename: format!("2026-01-01-{}.md", number),
2746                    body: String::new(),
2747                    history: crate::model::History::new(),
2748                    has_retired_links_key: false,
2749                    parent: None,
2750                    extra_frontmatter: std::collections::BTreeMap::new(),
2751                    archived: false,
2752                    entered_at: None,
2753                    source: None,
2754                })
2755                .collect(),
2756            texts: cases
2757                .iter()
2758                .map(|(_, title, _)| title.to_string())
2759                .collect(),
2760        };
2761        let config = Config::default();
2762        let (_, resolution) = crate::workflow::active_resolution(&config);
2763        let board_config = config.default_config();
2764        let status_todo = StructuredFilter {
2765            status: Some("todo".to_string()),
2766            ..Default::default()
2767        };
2768        let status_done = StructuredFilter {
2769            status: Some("done".to_string()),
2770            ..Default::default()
2771        };
2772
2773        // AND holds on the positive side: 55 must match number AND todo;
2774        // 156 matches the criteria but is dropped by the number gate.
2775        let matches = BoardState::build_filtered(
2776            board_config,
2777            &config,
2778            &corpus,
2779            "55",
2780            &status_todo,
2781            &resolution,
2782        );
2783        assert_eq!(matches.column_cards(0).len(), 1);
2784        assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
2785
2786        // Requirement scenario: number matches but the criterion fails
2787        // (issue 55 is "todo" here) => board shows nothing.
2788        let done_criteria = BoardState::build_filtered(
2789            board_config,
2790            &config,
2791            &corpus,
2792            "55",
2793            &status_done,
2794            &resolution,
2795        );
2796        assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
2797    }
2798
2799    #[test]
2800    fn is_empty_true_only_with_no_cards_anywhere() {
2801        let board = BoardState {
2802            columns: vec![BoardColumn {
2803                name: "a".into(),
2804                cards: vec![],
2805            }],
2806            mismatch: BoardColumn {
2807                name: "Mismatched".into(),
2808                cards: vec![],
2809            },
2810            active_col: 0,
2811            selected: 0,
2812            filter: None,
2813            hover: None,
2814            hidden: vec![],
2815            show_mismatch: true,
2816        };
2817        assert!(board.is_empty());
2818
2819        let populated = BoardState {
2820            columns: vec![BoardColumn {
2821                name: "a".into(),
2822                cards: vec![Card {
2823                    number: 1,
2824                    title: "X".into(),
2825                    priority: crate::model::Priority::Medium,
2826                    labels: vec![],
2827                    assignee: None,
2828                    filename: "x.md".into(),
2829                    commit: None,
2830                    created: "2026-01-01".into(),
2831                    due_date: None,
2832                    status: "todo".into(),
2833                    category: None,
2834                    category_color: None,
2835                    done: false,
2836                    cycle: None,
2837                    milestone: None,
2838                    blocked: None,
2839                    children: None,
2840                    archived: false,
2841                }],
2842            }],
2843            mismatch: BoardColumn {
2844                name: "Mismatched".into(),
2845                cards: vec![],
2846            },
2847            active_col: 0,
2848            selected: 0,
2849            filter: None,
2850            hover: None,
2851            hidden: vec![],
2852            show_mismatch: true,
2853        };
2854        assert!(!populated.is_empty());
2855    }
2856
2857    #[test]
2858    fn build_splits_hidden_columns_out_of_visible_surface() {
2859        use crate::config::{BoardConfiguration, Config};
2860
2861        let mut config = Config::default();
2862        config.kanban.configurations.insert(
2863            "default".to_string(),
2864            BoardConfiguration {
2865                columns: vec!["todo".into(), "done".into()],
2866                hidden_columns: vec!["done".into()],
2867                ..BoardConfiguration::default()
2868            },
2869        );
2870        let store = crate::storage::Store::open(&config).unwrap();
2871        let board = BoardState::build(&store, config.default_config());
2872        assert_eq!(board.columns.len(), 1);
2873        assert_eq!(board.columns[0].name, "todo");
2874        assert_eq!(board.hidden.len(), 1);
2875        assert_eq!(board.hidden[0].name, "done");
2876        assert_eq!(board.total_cols(), 1);
2877    }
2878
2879    #[test]
2880    fn move_left_skips_hidden_column() {
2881        let mut board = BoardState {
2882            columns: vec![BoardColumn {
2883                name: "visible-a".into(),
2884                cards: vec![],
2885            }],
2886            hidden: vec![BoardColumn {
2887                name: "hidden-b".into(),
2888                cards: vec![],
2889            }],
2890            mismatch: BoardColumn {
2891                name: "Mismatched".into(),
2892                cards: vec![],
2893            },
2894            active_col: 0,
2895            selected: 0,
2896            filter: None,
2897            hover: None,
2898            show_mismatch: true,
2899        };
2900        board.move_left();
2901        assert_eq!(board.active_col, 0, "single visible column wraps to itself");
2902    }
2903
2904    #[test]
2905    fn move_right_skips_hidden_column() {
2906        let mut board = BoardState {
2907            columns: vec![BoardColumn {
2908                name: "visible-a".into(),
2909                cards: vec![],
2910            }],
2911            hidden: vec![BoardColumn {
2912                name: "hidden-b".into(),
2913                cards: vec![],
2914            }],
2915            mismatch: BoardColumn {
2916                name: "Mismatched".into(),
2917                cards: vec![],
2918            },
2919            active_col: 0,
2920            selected: 0,
2921            filter: None,
2922            hover: None,
2923            show_mismatch: true,
2924        };
2925        board.move_right();
2926        assert_eq!(board.active_col, 0, "single visible column wraps to itself");
2927    }
2928
2929    #[test]
2930    fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
2931        let mut board = BoardState {
2932            columns: vec![],
2933            hidden: vec![BoardColumn {
2934                name: "only-hidden".into(),
2935                cards: vec![],
2936            }],
2937            mismatch: BoardColumn {
2938                name: "Mismatched".into(),
2939                cards: vec![],
2940            },
2941            active_col: 0,
2942            selected: 0,
2943            filter: None,
2944            hover: None,
2945            show_mismatch: false,
2946        };
2947        assert_eq!(board.total_cols(), 0);
2948        board.move_left();
2949        board.move_right();
2950        assert_eq!(board.active_col, 0, "zero-column board must not panic");
2951        let board2 = BoardState {
2952            columns: vec![],
2953            hidden: vec![],
2954            mismatch: BoardColumn {
2955                name: "Mismatched".into(),
2956                cards: vec![],
2957            },
2958            active_col: 0,
2959            selected: 0,
2960            filter: None,
2961            hover: None,
2962            show_mismatch: false,
2963        };
2964        assert_eq!(board2.total_cols(), 0);
2965    }
2966
2967    fn write_issue_md(
2968        dir: &std::path::Path,
2969        name: &str,
2970        number: u32,
2971        title: &str,
2972        status: &str,
2973        created: &str,
2974    ) {
2975        let contents = format!(
2976            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
2977        );
2978        std::fs::write(dir.join(name), contents).unwrap();
2979    }
2980
2981    #[test]
2982    fn order_by_sort_desc_asc_none() {
2983        let mk = |created: &str| Card {
2984            number: 1,
2985            title: created.to_string(),
2986            priority: crate::model::Priority::Medium,
2987            labels: vec![],
2988            assignee: None,
2989            filename: "x.md".into(),
2990            commit: None,
2991            created: created.to_string(),
2992            due_date: None,
2993            status: "todo".into(),
2994            category: None,
2995            category_color: None,
2996            done: false,
2997            cycle: None,
2998            milestone: None,
2999            blocked: None,
3000            children: None,
3001            archived: false,
3002        };
3003        let dates =
3004            |cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
3005
3006        let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
3007        crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
3008        assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
3009
3010        let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
3011        crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
3012        assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
3013
3014        let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
3015        crate::config::OrderBy::None.sort(&mut cards);
3016        assert_eq!(
3017            dates(&cards),
3018            ["2026-03-01", "2026-01-01"],
3019            "none is a no-op"
3020        );
3021    }
3022
3023    #[test]
3024    fn build_orders_most_recent_first_by_default() {
3025        use crate::config::Config;
3026        use std::path::PathBuf;
3027        let tmp = tempfile::TempDir::new().unwrap();
3028        let dir = tmp.path().join("issues");
3029        std::fs::create_dir_all(&dir).unwrap();
3030        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3031        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3032        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3033        let config = Config {
3034            issues_dir: PathBuf::from(&dir),
3035            ..Config::default()
3036        };
3037        let store = crate::storage::Store::open(&config).unwrap();
3038        let board = BoardState::build(&store, config.default_config());
3039        let titles: Vec<&str> = board
3040            .column_cards(0)
3041            .iter()
3042            .map(|c| c.title.as_str())
3043            .collect();
3044        assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
3045    }
3046
3047    #[test]
3048    fn build_applies_created_at_asc_and_none() {
3049        use crate::config::OrderBy;
3050        use std::path::PathBuf;
3051        let tmp = tempfile::TempDir::new().unwrap();
3052        let dir = tmp.path().join("issues");
3053        std::fs::create_dir_all(&dir).unwrap();
3054        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3055        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3056        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3057        let config = crate::config::Config {
3058            issues_dir: PathBuf::from(&dir),
3059            ..crate::config::Config::default()
3060        };
3061        let store = crate::storage::Store::open(&config).unwrap();
3062
3063        let mut cfg_asc = config.clone();
3064        cfg_asc
3065            .kanban
3066            .configurations
3067            .get_mut("default")
3068            .unwrap()
3069            .order_by = OrderBy::CreatedAtAsc;
3070        let board_asc = BoardState::build(&store, cfg_asc.default_config());
3071        let titles: Vec<&str> = board_asc
3072            .column_cards(0)
3073            .iter()
3074            .map(|c| c.title.as_str())
3075            .collect();
3076        assert_eq!(titles, ["old", "mid", "new"]);
3077
3078        let mut cfg_none = config;
3079        cfg_none
3080            .kanban
3081            .configurations
3082            .get_mut("default")
3083            .unwrap()
3084            .order_by = OrderBy::None;
3085        let board_none = BoardState::build(&store, cfg_none.default_config());
3086        let titles: Vec<&str> = board_none
3087            .column_cards(0)
3088            .iter()
3089            .map(|c| c.title.as_str())
3090            .collect();
3091        assert_eq!(
3092            titles,
3093            ["old", "mid", "new"],
3094            "none keeps the store's filename-ascending order"
3095        );
3096    }
3097
3098    #[test]
3099    fn build_applies_sparse_manual_card_order_when_present() {
3100        use crate::config::Config;
3101        use std::path::PathBuf;
3102        let tmp = tempfile::TempDir::new().unwrap();
3103        let dir = tmp.path().join("issues");
3104        std::fs::create_dir_all(&dir).unwrap();
3105        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3106        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3107        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3108        let config = Config {
3109            issues_dir: PathBuf::from(&dir),
3110            ..Config::default()
3111        };
3112        let store = crate::storage::Store::open(&config).unwrap();
3113        let mut board_cfg = config.default_config().clone();
3114        board_cfg.card_order = vec!["2026-02-01-mid.md".into(), "2026-03-01-new.md".into()];
3115        let board = BoardState::build(&store, &board_cfg);
3116        let titles: Vec<&str> = board
3117            .column_cards(0)
3118            .iter()
3119            .map(|c| c.title.as_str())
3120            .collect();
3121        assert_eq!(titles, ["mid", "new", "old"], "manual order wins");
3122    }
3123
3124    #[test]
3125    fn sparse_card_order_only_returns_changed_cards() {
3126        use crate::config::Config;
3127        use std::path::PathBuf;
3128
3129        let tmp = tempfile::TempDir::new().unwrap();
3130        let dir = tmp.path().join("issues");
3131        std::fs::create_dir_all(&dir).unwrap();
3132        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
3133        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
3134        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
3135        let config = Config {
3136            issues_dir: PathBuf::from(&dir),
3137            ..Config::default()
3138        };
3139        let store = crate::storage::Store::open(&config).unwrap();
3140        let board_cfg = config.default_config().clone();
3141        let mut cards = BoardState::build(&store, &board_cfg)
3142            .column_cards(0)
3143            .to_vec();
3144        cards.swap(0, 1);
3145        let sparse = sparse_card_order(&board_cfg, &cards);
3146        assert_eq!(sparse, ["2026-02-01-mid.md", "2026-03-01-new.md"]);
3147    }
3148
3149    #[test]
3150    fn build_filtered_applies_order_by() {
3151        use crate::model::Issue;
3152        use crate::search::{Corpus, StructuredFilter};
3153
3154        let to_issue = |number: u32, title: &str, created: &str| Issue {
3155            number,
3156            title: title.to_string(),
3157            status: crate::model::Status::new("todo"),
3158            labels: vec![],
3159            priority: crate::model::Priority::Medium,
3160            assignee: None,
3161            estimate: None,
3162            commit: None,
3163            gitlab_id: None,
3164            linear_id: None,
3165            mission: None,
3166            task: None,
3167            relationships: vec![],
3168            files: vec![],
3169            cycles: None,
3170            milestones: None,
3171            reviewer: None,
3172            start_date: None,
3173            due_date: None,
3174            created: created.into(),
3175            updated: "2026-01-01".into(),
3176            filename: format!("2026-01-01-{number}.md"),
3177            body: String::new(),
3178            history: crate::model::History::new(),
3179            has_retired_links_key: false,
3180            parent: None,
3181            extra_frontmatter: std::collections::BTreeMap::new(),
3182            archived: false,
3183            entered_at: None,
3184            source: None,
3185        };
3186        let corpus = Corpus {
3187            issues: vec![
3188                to_issue(1, "old", "2026-01-01"),
3189                to_issue(2, "new", "2026-03-01"),
3190                to_issue(3, "mid", "2026-02-01"),
3191            ],
3192            texts: vec!["old".into(), "new".into(), "mid".into()],
3193        };
3194        let config = crate::config::Config::default();
3195        let (_, resolution) = crate::workflow::active_resolution(&config);
3196        let board = BoardState::build_filtered(
3197            config.default_config(),
3198            &config,
3199            &corpus,
3200            "",
3201            &StructuredFilter::default(),
3202            &resolution,
3203        );
3204        let titles: Vec<&str> = board
3205            .column_cards(0)
3206            .iter()
3207            .map(|c| c.title.as_str())
3208            .collect();
3209        assert_eq!(
3210            titles,
3211            ["new", "mid", "old"],
3212            "default desc applies to filtered results too"
3213        );
3214    }
3215}