Skip to main content

tix/ui/
board.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
6
7use crate::config::BoardConfiguration;
8use crate::model::Priority;
9use crate::search::{Corpus, StructuredFilter};
10use crate::storage::Store;
11
12use super::widgets;
13
14#[derive(Debug, Clone)]
15pub struct Card {
16    pub number: u32,
17    pub title: String,
18    pub priority: Priority,
19    pub labels: Vec<String>,
20    pub assignee: Option<String>,
21    pub filename: String,
22    pub commit: Option<String>,
23    pub created: String,
24}
25
26impl crate::config::OrderBy {
27    /// Sort cards in place per the board's `order_by` setting. `none` is a
28    /// no-op. `sort_by` is stable, so equal creation dates keep the
29    /// underlying store order (relative filename ascending) — deterministic
30    /// ties with no extra key. Dates are zero-padded `YYYY-MM-DD`, so
31    /// lexicographic comparison is chronological.
32    pub fn sort(&self, cards: &mut [Card]) {
33        match self {
34            crate::config::OrderBy::CreatedAtDesc => {
35                cards.sort_by(|a, b| b.created.cmp(&a.created))
36            }
37            crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
38            crate::config::OrderBy::None => {}
39        }
40    }
41}
42
43#[derive(Debug, Clone)]
44pub struct BoardColumn {
45    pub name: String,
46    pub cards: Vec<Card>,
47}
48
49#[derive(Debug, Clone)]
50pub struct BoardState {
51    /// Visible columns only — configured columns not listed in the active
52    /// board's `hidden_columns`, in declaration order (FR-002/FR-003). Hidden
53    /// columns live in [`BoardState::hidden`].
54    pub columns: Vec<BoardColumn>,
55    /// Columns hidden from the active board configuration. Their cards stay
56    /// in board state (keyed by column name) and remain reachable via
57    /// filters/search (DM-01M2MHJB884WCTVCDHPD2T3GE7); they never render.
58    pub hidden: Vec<BoardColumn>,
59    pub mismatch: BoardColumn,
60    /// Whether the computed "Mismatched" pseudo-column is part of the
61    /// navigation/render surface (C-002 / FR-003). Cards in unknown statuses
62    /// are always collected into [`BoardState::mismatch`]; the column is only
63    /// shown when the effective configuration sets `show_mismatch`.
64    pub show_mismatch: bool,
65    pub active_col: usize,
66    pub selected: usize,
67    pub filter: Option<String>,
68}
69
70impl BoardState {
71    pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
72        let issues = store.list().unwrap_or_default();
73        let show_mismatch = board_config.show_mismatch;
74        let hidden_names: Vec<&str> = board_config
75            .hidden_columns
76            .iter()
77            .map(String::as_str)
78            .collect();
79        let mut column_map: Vec<Vec<Card>> =
80            board_config.columns.iter().map(|_| Vec::new()).collect();
81        let mut mismatch_cards = Vec::new();
82
83        for issue in issues {
84            let card = Card {
85                number: issue.number,
86                title: issue.title.clone(),
87                priority: issue.priority.clone(),
88                labels: issue.labels.clone(),
89                assignee: issue.assignee.clone(),
90                filename: issue.filename.clone(),
91                commit: issue.commit.clone(),
92                created: issue.created.clone(),
93            };
94            if let Some(idx) = board_config
95                .columns
96                .iter()
97                .position(|c| c == issue.status.display())
98            {
99                column_map[idx].push(card);
100            } else {
101                mismatch_cards.push(card);
102            }
103        }
104
105        for cards in column_map.iter_mut() {
106            board_config.order_by.sort(cards);
107        }
108        board_config.order_by.sort(&mut mismatch_cards);
109
110        let mut columns: Vec<BoardColumn> = Vec::new();
111        let mut hidden: Vec<BoardColumn> = Vec::new();
112        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
113            let col = BoardColumn {
114                name: name.clone(),
115                cards,
116            };
117            if hidden_names.contains(&name.as_str()) {
118                hidden.push(col);
119            } else {
120                columns.push(col);
121            }
122        }
123
124        let mismatch = BoardColumn {
125            name: "Mismatched".to_string(),
126            cards: mismatch_cards,
127        };
128
129        BoardState {
130            columns,
131            hidden,
132            mismatch,
133            show_mismatch,
134            active_col: 0,
135            selected: 0,
136            filter: None,
137        }
138    }
139
140    /// Build a board from the search corpus, keeping only cards whose full
141    /// serialized text matches **both** the fuzzy `query` layer and the
142    /// structured `filter` layer (FR-012/FR-013). Both predicates run in a
143    /// single pass over the corpus via [`crate::search::matches`] (NFR-001).
144    ///
145    /// When a filter is active (non-empty query or structured criteria set),
146    /// hidden columns' matching cards are **revealed** in the rendered surface
147    /// (DM-01M2MHJB884WCTVCDHPD2T3GE7: hidden-column cards reachable via
148    /// filters/search). When no filter is active, matching cards from hidden
149    /// columns are stashed in [`BoardState::hidden`] (no visual presence).
150    pub fn build_filtered(
151        board_config: &BoardConfiguration,
152        corpus: &Corpus,
153        query: &str,
154        filter: &StructuredFilter,
155    ) -> Self {
156        let show_mismatch = board_config.show_mismatch;
157        let filtering = !query.is_empty() || filter.has_active_criteria();
158        // A bare-number query (all-ASCII-digit after trim) is an exact issue
159        // number match, not a fuzzy digit search: only the issue whose
160        // `number` equals it survives, still ANDed with the structured
161        // filter (board-filtering FR-02 / FR-03). Any other query keeps the
162        // fuzzy-text path.
163        let number = crate::search::number_query(query);
164        let hidden_names: Vec<&str> = board_config
165            .hidden_columns
166            .iter()
167            .map(String::as_str)
168            .collect();
169        let mut column_map: Vec<Vec<Card>> =
170            board_config.columns.iter().map(|_| Vec::new()).collect();
171        let mut mismatch_cards = Vec::new();
172
173        for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
174            // Bare-number query: exact issue-number match replaces the fuzzy
175            // text layer (board-filtering FR-02); the structured filter still
176            // runs (FR-03). Otherwise the composite fuzzy+structured
177            // predicate applies unchanged.
178            let keep = if let Some(n) = number {
179                issue.number == n && filter.evaluate(text)
180            } else {
181                crate::search::matches(query, filter, text)
182            };
183            if !keep {
184                continue;
185            }
186            let card = Card {
187                number: issue.number,
188                title: issue.title.clone(),
189                priority: issue.priority.clone(),
190                labels: issue.labels.clone(),
191                assignee: issue.assignee.clone(),
192                filename: issue.filename.clone(),
193                commit: issue.commit.clone(),
194                created: issue.created.clone(),
195            };
196            if let Some(idx) = board_config
197                .columns
198                .iter()
199                .position(|c| c == issue.status.display())
200            {
201                column_map[idx].push(card);
202            } else {
203                mismatch_cards.push(card);
204            }
205        }
206
207        for cards in column_map.iter_mut() {
208            board_config.order_by.sort(cards);
209        }
210        board_config.order_by.sort(&mut mismatch_cards);
211
212        let mut columns: Vec<BoardColumn> = Vec::new();
213        let mut hidden: Vec<BoardColumn> = Vec::new();
214        for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
215            let col = BoardColumn {
216                name: name.clone(),
217                cards,
218            };
219            if hidden_names.contains(&name.as_str()) {
220                if filtering {
221                    // Reveal hidden column when a filter is active so
222                    // matching cards appear in the rendered surface
223                    // (DM-01M2MHJB884WCTVCDHPD2T3GE7).
224                    columns.push(col);
225                } else {
226                    hidden.push(col);
227                }
228            } else {
229                columns.push(col);
230            }
231        }
232
233        BoardState {
234            columns,
235            hidden,
236            mismatch: BoardColumn {
237                name: "Mismatched".to_string(),
238                cards: mismatch_cards,
239            },
240            show_mismatch,
241            active_col: 0,
242            selected: 0,
243            filter: Some(query.to_string()),
244        }
245    }
246
247    /// True when no card anywhere is visible (used for zero-match feedback).
248    pub fn is_empty(&self) -> bool {
249        self.columns.iter().all(|c| c.cards.is_empty())
250            && (!self.show_mismatch || self.mismatch.cards.is_empty())
251    }
252
253    pub fn total_cols(&self) -> usize {
254        if self.show_mismatch {
255            self.columns.len() + 1
256        } else {
257            self.columns.len()
258        }
259    }
260
261    /// Columns a card can be moved into: the configured columns plus (when
262    /// shown) the "Mismatched" sentinel, which is never a move target. The
263    /// `</>` move keys navigate *among movable columns* only.
264    pub fn real_cols(&self) -> usize {
265        self.columns.len()
266    }
267
268    pub fn column_name(&self, idx: usize) -> &str {
269        if idx < self.columns.len() {
270            &self.columns[idx].name
271        } else if idx == self.columns.len() && self.show_mismatch {
272            &self.mismatch.name
273        } else {
274            "Unknown"
275        }
276    }
277
278    /// The name under the active cursor, resolving **hidden** columns too
279    /// (visible region first, then hidden, then Unknown). Lets `toggle` keep a
280    /// logical cursor on a just-hidden column so a second press re-shows it
281    /// (T017: "a second press on the same column re-shows it").
282    pub fn active_column_name(&self) -> &str {
283        let visible = self.columns.len();
284        if self.active_col < visible {
285            &self.columns[self.active_col].name
286        } else {
287            let hidden_idx = self.active_col - visible;
288            if hidden_idx < self.hidden.len() {
289                &self.hidden[hidden_idx].name
290            } else {
291                "Unknown"
292            }
293        }
294    }
295
296    pub fn column_cards(&self, idx: usize) -> &[Card] {
297        if idx < self.columns.len() {
298            &self.columns[idx].cards
299        } else if idx == self.columns.len() && self.show_mismatch {
300            &self.mismatch.cards
301        } else {
302            &[]
303        }
304    }
305
306    pub fn get_selected_card(&self) -> Option<&Card> {
307        self.column_cards(self.active_col).get(self.selected)
308    }
309
310    pub fn move_left(&mut self) {
311        let total = self.real_cols();
312        self.active_col = if total == 0 {
313            0
314        } else if self.active_col >= total {
315            total - 1
316        } else {
317            (self.active_col + total - 1) % total
318        };
319        self.clamp_selection();
320    }
321
322    pub fn move_right(&mut self) {
323        let total = self.real_cols();
324        self.active_col = if total == 0 || self.active_col >= total {
325            0
326        } else {
327            (self.active_col + 1) % total
328        };
329        self.clamp_selection();
330    }
331
332    pub fn move_up(&mut self) {
333        if self.selected > 0 {
334            self.selected -= 1;
335        }
336    }
337
338    pub fn move_down(&mut self) {
339        let cards = self.column_cards(self.active_col);
340        if self.selected + 1 < cards.len() {
341            self.selected += 1;
342        }
343    }
344
345    pub fn clamp_selection(&mut self) {
346        let cards = self.column_cards(self.active_col);
347        if cards.is_empty() {
348            self.selected = 0;
349        } else if self.selected >= cards.len() {
350            self.selected = cards.len() - 1;
351        }
352    }
353
354    pub fn select_column(&mut self, col: usize) {
355        let total = self.real_cols();
356        if total > 0 {
357            self.active_col = col % total;
358        }
359        self.clamp_selection();
360    }
361}
362
363/// Render options carried from the active board configuration into the board
364/// draw. Kept tiny and copyable so tests and call sites stay light.
365#[derive(Debug, Clone, Copy)]
366pub struct BoardDisplay {
367    pub wrap_titles: bool,
368    pub title_lines: usize,
369}
370
371impl Default for BoardDisplay {
372    fn default() -> Self {
373        BoardDisplay {
374            wrap_titles: true,
375            title_lines: 3,
376        }
377    }
378}
379
380impl From<&crate::config::BoardConfiguration> for BoardDisplay {
381    fn from(config: &crate::config::BoardConfiguration) -> Self {
382        BoardDisplay {
383            wrap_titles: config.wrap_titles,
384            title_lines: config.title_lines,
385        }
386    }
387}
388
389pub fn render(
390    board: &BoardState,
391    f: &mut Frame,
392    area: Rect,
393    status_msg: &Option<String>,
394    display: BoardDisplay,
395) {
396    if area.height < 2 || area.width < 4 {
397        return;
398    }
399
400    let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
401    let board_height = area.height.saturating_sub(status_height);
402    let board_area = Rect {
403        height: board_height,
404        ..area
405    };
406    let status_area = Rect {
407        y: area.y + board_height,
408        height: status_height,
409        ..area
410    };
411
412    let total = board.total_cols();
413    if total == 0 {
414        return;
415    }
416
417    let chunks = Layout::default()
418        .direction(Direction::Horizontal)
419        .constraints(vec![Constraint::Ratio(1, total as u32); total])
420        .split(board_area);
421
422    for i in 0..total {
423        let col = if i < board.columns.len() {
424            &board.columns[i]
425        } else {
426            &board.mismatch
427        };
428
429        let is_active = i == board.active_col;
430        let is_mismatch_col = i == board.columns.len();
431        let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
432
433        let items: Vec<ListItem> = col
434            .cards
435            .iter()
436            .enumerate()
437            .map(|(j, card)| {
438                let lines = widgets::card_lines(
439                    card,
440                    chunks[i].width as usize,
441                    display.wrap_titles,
442                    display.title_lines,
443                );
444                let style = if is_active && j == board.selected {
445                    Style::default()
446                        .bg(Color::DarkGray)
447                        .add_modifier(Modifier::BOLD)
448                } else if is_mismatch_highlight {
449                    Style::default().fg(Color::Red)
450                } else {
451                    Style::default()
452                };
453                ListItem::new(lines).style(style)
454            })
455            .collect();
456
457        let title = format!("{} ({})", col.name, col.cards.len());
458        let border_style = if is_active {
459            Style::default().fg(Color::Cyan)
460        } else if is_mismatch_highlight {
461            Style::default().fg(Color::Red)
462        } else {
463            Style::default()
464        };
465
466        let block = Block::default()
467            .borders(Borders::ALL)
468            .title(title)
469            .border_style(border_style);
470
471        let list = List::new(items).block(block);
472
473        if is_active {
474            let mut state = ListState::default();
475            if !col.cards.is_empty() {
476                state.select(Some(board.selected));
477            }
478            f.render_stateful_widget(list, chunks[i], &mut state);
479        } else {
480            f.render_widget(list, chunks[i]);
481        }
482    }
483
484    if let Some(msg) = status_msg {
485        let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
486            .style(Style::default().fg(Color::Yellow));
487        f.render_widget(para, status_area);
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::config::Config;
495
496    #[test]
497    fn total_cols_includes_mismatch() {
498        let board = BoardState {
499            columns: vec![
500                BoardColumn {
501                    name: "a".into(),
502                    cards: vec![],
503                },
504                BoardColumn {
505                    name: "b".into(),
506                    cards: vec![],
507                },
508            ],
509            mismatch: BoardColumn {
510                name: "Mismatched".into(),
511                cards: vec![],
512            },
513            active_col: 0,
514            selected: 0,
515            filter: None,
516            hidden: vec![],
517            show_mismatch: true,
518        };
519        assert_eq!(board.total_cols(), 3);
520    }
521
522    #[test]
523    fn column_name_mismatch_index() {
524        let board = BoardState {
525            columns: vec![BoardColumn {
526                name: "todo".into(),
527                cards: vec![],
528            }],
529            mismatch: BoardColumn {
530                name: "Mismatched".into(),
531                cards: vec![],
532            },
533            active_col: 0,
534            selected: 0,
535            filter: None,
536            hidden: vec![],
537            show_mismatch: true,
538        };
539        assert_eq!(board.column_name(0), "todo");
540        assert_eq!(board.column_name(1), "Mismatched");
541    }
542
543    #[test]
544    fn move_left_wraps_around() {
545        let mut board = BoardState {
546            columns: vec![
547                BoardColumn {
548                    name: "a".into(),
549                    cards: vec![],
550                },
551                BoardColumn {
552                    name: "b".into(),
553                    cards: vec![],
554                },
555            ],
556            hidden: vec![],
557            mismatch: BoardColumn {
558                name: "Mismatched".into(),
559                cards: vec![],
560            },
561            active_col: 0,
562            selected: 0,
563            filter: None,
564            show_mismatch: true,
565        };
566        board.move_left();
567        assert_eq!(board.active_col, 1);
568    }
569
570    #[test]
571    fn move_right_wraps_around() {
572        let mut board = BoardState {
573            columns: vec![
574                BoardColumn {
575                    name: "a".into(),
576                    cards: vec![],
577                },
578                BoardColumn {
579                    name: "b".into(),
580                    cards: vec![],
581                },
582            ],
583            mismatch: BoardColumn {
584                name: "Mismatched".into(),
585                cards: vec![],
586            },
587            active_col: 2,
588            selected: 0,
589            filter: None,
590            hidden: vec![],
591            show_mismatch: true,
592        };
593        board.move_right();
594        assert_eq!(board.active_col, 0);
595    }
596
597    #[test]
598    fn clamp_selection_empty_column() {
599        let mut board = BoardState {
600            columns: vec![BoardColumn {
601                name: "a".into(),
602                cards: vec![],
603            }],
604            mismatch: BoardColumn {
605                name: "Mismatched".into(),
606                cards: vec![],
607            },
608            active_col: 0,
609            selected: 5,
610            filter: None,
611            hidden: vec![],
612            show_mismatch: true,
613        };
614        board.clamp_selection();
615        assert_eq!(board.selected, 0);
616    }
617
618    #[test]
619    fn clamp_selection_single_card() {
620        let mut board = BoardState {
621            columns: vec![BoardColumn {
622                name: "a".into(),
623                cards: vec![Card {
624                    number: 1,
625                    title: "X".into(),
626                    priority: crate::model::Priority::Medium,
627                    labels: vec![],
628                    assignee: None,
629                    filename: "x.md".into(),
630                    commit: None,
631                    created: "2026-01-01".into(),
632                }],
633            }],
634            mismatch: BoardColumn {
635                name: "Mismatched".into(),
636                cards: vec![],
637            },
638            active_col: 0,
639            selected: 5,
640            filter: None,
641            hidden: vec![],
642            show_mismatch: true,
643        };
644        board.clamp_selection();
645        assert_eq!(board.selected, 0);
646    }
647
648    #[test]
649    fn move_up_clamps_at_top() {
650        let mut board = BoardState {
651            columns: vec![BoardColumn {
652                name: "a".into(),
653                cards: vec![Card {
654                    number: 1,
655                    title: "X".into(),
656                    priority: crate::model::Priority::Medium,
657                    labels: vec![],
658                    assignee: None,
659                    filename: "x.md".into(),
660                    commit: None,
661                    created: "2026-01-01".into(),
662                }],
663            }],
664            mismatch: BoardColumn {
665                name: "Mismatched".into(),
666                cards: vec![],
667            },
668            active_col: 0,
669            selected: 0,
670            filter: None,
671            hidden: vec![],
672            show_mismatch: true,
673        };
674        board.move_up();
675        assert_eq!(board.selected, 0);
676    }
677
678    #[test]
679    fn move_down_clamps_at_bottom() {
680        let mut board = BoardState {
681            columns: vec![BoardColumn {
682                name: "a".into(),
683                cards: vec![Card {
684                    number: 1,
685                    title: "X".into(),
686                    priority: crate::model::Priority::Medium,
687                    labels: vec![],
688                    assignee: None,
689                    filename: "x.md".into(),
690                    commit: None,
691                    created: "2026-01-01".into(),
692                }],
693            }],
694            mismatch: BoardColumn {
695                name: "Mismatched".into(),
696                cards: vec![],
697            },
698            active_col: 0,
699            selected: 0,
700            filter: None,
701            hidden: vec![],
702            show_mismatch: true,
703        };
704        board.move_down();
705        assert_eq!(board.selected, 0);
706    }
707
708    #[test]
709    fn render_smoke_does_not_panic_on_tiny_frame() {
710        use ratatui::Terminal;
711        use ratatui::backend::TestBackend;
712
713        let backend = TestBackend::new(5, 3);
714        let mut terminal = Terminal::new(backend).unwrap();
715        let board = BoardState {
716            columns: vec![],
717            mismatch: BoardColumn {
718                name: "Mismatched".into(),
719                cards: vec![],
720            },
721            active_col: 0,
722            selected: 0,
723            filter: None,
724            hidden: vec![],
725            show_mismatch: true,
726        };
727        terminal
728            .draw(|f| render(&board, f, f.area(), &None, BoardDisplay::default()))
729            .unwrap();
730    }
731
732    #[test]
733    fn render_smoke_full_board() {
734        use ratatui::Terminal;
735        use ratatui::backend::TestBackend;
736
737        let backend = TestBackend::new(80, 24);
738        let mut terminal = Terminal::new(backend).unwrap();
739        let board = BoardState {
740            columns: vec![
741                BoardColumn {
742                    name: "todo".into(),
743                    cards: vec![],
744                },
745                BoardColumn {
746                    name: "done".into(),
747                    cards: vec![],
748                },
749            ],
750            mismatch: BoardColumn {
751                name: "Mismatched".into(),
752                cards: vec![],
753            },
754            active_col: 0,
755            selected: 0,
756            filter: None,
757            hidden: vec![],
758            show_mismatch: true,
759        };
760        terminal
761            .draw(|f| {
762                render(
763                    &board,
764                    f,
765                    f.area(),
766                    &Some("test msg".into()),
767                    BoardDisplay::default(),
768                )
769            })
770            .unwrap();
771    }
772
773    #[test]
774    fn render_selected_multiline_card_highlights_all_wrapped_lines() {
775        use ratatui::Terminal;
776        use ratatui::backend::TestBackend;
777
778        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";
779        let card = crate::ui::board::Card {
780            number: 7,
781            title: title.into(),
782            priority: crate::model::Priority::Medium,
783            labels: vec![],
784            assignee: None,
785            filename: "x.md".into(),
786            commit: None,
787            created: "2026-01-01".into(),
788        };
789        let backend = TestBackend::new(80, 24);
790        let mut terminal = Terminal::new(backend).unwrap();
791        let board = BoardState {
792            columns: vec![BoardColumn {
793                name: "todo".into(),
794                cards: vec![card],
795            }],
796            mismatch: BoardColumn {
797                name: "Mismatched".into(),
798                cards: vec![],
799            },
800            active_col: 0,
801            selected: 0,
802            filter: None,
803            hidden: vec![],
804            show_mismatch: false,
805        };
806        let display = BoardDisplay {
807            wrap_titles: true,
808            title_lines: 3,
809        };
810        terminal
811            .draw(|f| render(&board, f, f.area(), &None, display))
812            .unwrap();
813        let buffer = terminal.backend().buffer();
814
815        // The card is the only item in the only column; its block top border
816        // sits on y=0, so the ListItem starts at y=1 and each wrapped line
817        // must carry the selected-card highlight (DarkGray bg + BOLD).
818        let mut highlighted = 0;
819        for y in 1..buffer.area().height - 1 {
820            let cell = &buffer[(2, y)];
821            if cell.bg != Color::DarkGray {
822                break;
823            }
824            assert!(
825                cell.modifier.contains(Modifier::BOLD),
826                "highlighted continuation line must stay bold: {cell:?}"
827            );
828            highlighted += 1;
829        }
830        assert!(
831            highlighted >= 2,
832            "wrapped card highlights every line, got {highlighted}"
833        );
834        assert!(
835            highlighted <= display.title_lines,
836            "highlight never exceeds the line limit: {highlighted}"
837        );
838    }
839
840    #[test]
841    fn build_filtered_keeps_only_fuzzy_matches() {
842        use crate::model::Issue;
843        use crate::search::Corpus;
844
845        let to_issue = |number: u32, title: &str| Issue {
846            number,
847            title: title.to_string(),
848            status: crate::model::Status::new("todo"),
849            labels: vec![],
850            priority: crate::model::Priority::Medium,
851            assignee: None,
852            estimate: None,
853            commit: None,
854            gitlab_id: None,
855            linear_id: None,
856            mission: None,
857            task: None,
858            links: vec![],
859            files: vec![],
860            created: "2026-01-01".into(),
861            updated: "2026-01-01".into(),
862            filename: format!("2026-01-01-{number}.md"),
863            body: String::new(),
864        };
865        let corpus = Corpus {
866            issues: vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
867            texts: vec!["Fix login bug".to_string(), "Polish exports".to_string()],
868        };
869        let config = Config::default();
870        let board_config = config.default_config();
871
872        let board =
873            BoardState::build_filtered(board_config, &corpus, "log", &StructuredFilter::default());
874        assert_eq!(board.columns.len(), board_config.columns.len());
875        assert_eq!(board.column_cards(0).len(), 1);
876        assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
877        assert_eq!(board.filter.as_deref(), Some("log"));
878
879        let none =
880            BoardState::build_filtered(board_config, &corpus, "zzw", &StructuredFilter::default());
881        assert!(none.is_empty());
882
883        let empty =
884            BoardState::build_filtered(board_config, &corpus, "", &StructuredFilter::default());
885        assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
886        assert!(empty.filter.is_some());
887    }
888
889    #[test]
890    fn build_filtered_bare_number_matches_exact_issue_only() {
891        use crate::model::Issue;
892        use crate::search::Corpus;
893
894        let to_issue = |number: u32, title: &str, status: &str| Issue {
895            number,
896            title: title.to_string(),
897            status: crate::model::Status::new(status),
898            labels: vec![],
899            priority: crate::model::Priority::Medium,
900            assignee: None,
901            estimate: None,
902            commit: None,
903            gitlab_id: None,
904            linear_id: None,
905            mission: None,
906            task: None,
907            links: vec![],
908            files: vec![],
909            created: "2026-01-01".into(),
910            updated: "2026-01-01".into(),
911            filename: format!("2026-01-01-{number}.md"),
912            body: String::new(),
913        };
914        let corpus = Corpus {
915            issues: vec![
916                to_issue(55, "Concrete target", "todo"),
917                to_issue(155, "Digit-rich title", "todo"),
918                to_issue(3, "Login bug", "todo"),
919            ],
920            texts: vec![
921                "Concrete target".to_string(),
922                "Digit-rich title".to_string(),
923                "Login bug".to_string(),
924            ],
925        };
926        let config = Config::default();
927        let board_config = config.default_config();
928
929        let exact =
930            BoardState::build_filtered(board_config, &corpus, "55", &StructuredFilter::default());
931        assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
932        assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
933        assert_eq!(
934            exact.column_cards(0)[0].number,
935            55,
936            "issue 155's digit sequence must not qualify"
937        );
938        assert_eq!(exact.filter.as_deref(), Some("55"));
939
940        // Number miss -> empty board.
941        let miss =
942            BoardState::build_filtered(board_config, &corpus, "999", &StructuredFilter::default());
943        assert!(miss.is_empty(), "no issue 999 => zero matches");
944
945        // Non-numeric query keeps fuzzy behavior (login finds issue 3 only).
946        let fuzzy = BoardState::build_filtered(
947            board_config,
948            &corpus,
949            "login",
950            &StructuredFilter::default(),
951        );
952        assert_eq!(fuzzy.column_cards(0).len(), 1);
953        assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
954    }
955
956    #[test]
957    fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
958        use crate::model::Issue;
959        use crate::search::Corpus;
960
961        let to_issue = |number: u32, title: &str| Issue {
962            number,
963            title: title.to_string(),
964            status: crate::model::Status::new("todo"),
965            labels: vec![],
966            priority: crate::model::Priority::Medium,
967            assignee: None,
968            estimate: None,
969            commit: None,
970            gitlab_id: None,
971            linear_id: None,
972            mission: None,
973            task: None,
974            links: vec![],
975            files: vec![],
976            created: "2026-01-01".into(),
977            updated: "2026-01-01".into(),
978            filename: format!("2026-01-01-{number}.md"),
979            body: String::new(),
980        };
981        let corpus = Corpus {
982            issues: vec![
983                to_issue(55, "Concrete target"),
984                to_issue(155, "Digit-rich title"),
985            ],
986            texts: vec![
987                "Concrete target".to_string(),
988                "Digit-rich title".to_string(),
989            ],
990        };
991        let config = Config::default();
992        let board_config = config.default_config();
993
994        // "55bug" is not a bare number: fuzzy path applies (no such text, so
995        // zero matches rather than exact issue 55).
996        let mixed = BoardState::build_filtered(
997            board_config,
998            &corpus,
999            "55bug",
1000            &StructuredFilter::default(),
1001        );
1002        assert!(mixed.is_empty(), "mixed query is not an exact-number query");
1003    }
1004
1005    #[test]
1006    fn build_filtered_number_composes_with_structured_filter_and() {
1007        use crate::model::Issue;
1008        use crate::search::Corpus;
1009
1010        let cases = [
1011            (55u32, "Issue 55 todo", "todo"),
1012            (156u32, "Issue 156 todo", "todo"),
1013            (57u32, "Issue 57 done", "done"),
1014        ];
1015        let corpus = Corpus {
1016            issues: cases
1017                .iter()
1018                .map(|&(number, title, status)| Issue {
1019                    number,
1020                    title: title.to_string(),
1021                    status: crate::model::Status::new(status),
1022                    labels: vec![],
1023                    priority: crate::model::Priority::Medium,
1024                    assignee: None,
1025                    estimate: None,
1026                    commit: None,
1027                    gitlab_id: None,
1028                    linear_id: None,
1029                    mission: None,
1030                    task: None,
1031                    links: vec![],
1032                    files: vec![],
1033                    created: "2026-01-01".into(),
1034                    updated: "2026-01-01".into(),
1035                    filename: format!("2026-01-01-{}.md", number),
1036                    body: String::new(),
1037                })
1038                .collect(),
1039            texts: cases
1040                .iter()
1041                .map(|(_, title, _)| title.to_string())
1042                .collect(),
1043        };
1044        let config = Config::default();
1045        let board_config = config.default_config();
1046        let status_todo = StructuredFilter {
1047            status: Some("todo".to_string()),
1048            ..Default::default()
1049        };
1050        let status_done = StructuredFilter {
1051            status: Some("done".to_string()),
1052            ..Default::default()
1053        };
1054
1055        // AND holds on the positive side: 55 must match number AND todo;
1056        // 156 matches the criteria but is dropped by the number gate.
1057        let matches = BoardState::build_filtered(board_config, &corpus, "55", &status_todo);
1058        assert_eq!(matches.column_cards(0).len(), 1);
1059        assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
1060
1061        // Requirement scenario: number matches but the criterion fails
1062        // (issue 55 is "todo" here) => board shows nothing.
1063        let done_criteria = BoardState::build_filtered(board_config, &corpus, "55", &status_done);
1064        assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
1065    }
1066
1067    #[test]
1068    fn is_empty_true_only_with_no_cards_anywhere() {
1069        let board = BoardState {
1070            columns: vec![BoardColumn {
1071                name: "a".into(),
1072                cards: vec![],
1073            }],
1074            mismatch: BoardColumn {
1075                name: "Mismatched".into(),
1076                cards: vec![],
1077            },
1078            active_col: 0,
1079            selected: 0,
1080            filter: None,
1081            hidden: vec![],
1082            show_mismatch: true,
1083        };
1084        assert!(board.is_empty());
1085
1086        let populated = BoardState {
1087            columns: vec![BoardColumn {
1088                name: "a".into(),
1089                cards: vec![Card {
1090                    number: 1,
1091                    title: "X".into(),
1092                    priority: crate::model::Priority::Medium,
1093                    labels: vec![],
1094                    assignee: None,
1095                    filename: "x.md".into(),
1096                    commit: None,
1097                    created: "2026-01-01".into(),
1098                }],
1099            }],
1100            mismatch: BoardColumn {
1101                name: "Mismatched".into(),
1102                cards: vec![],
1103            },
1104            active_col: 0,
1105            selected: 0,
1106            filter: None,
1107            hidden: vec![],
1108            show_mismatch: true,
1109        };
1110        assert!(!populated.is_empty());
1111    }
1112
1113    #[test]
1114    fn build_splits_hidden_columns_out_of_visible_surface() {
1115        use crate::config::{BoardConfiguration, Config};
1116
1117        let mut config = Config::default();
1118        config.kanban.configurations.insert(
1119            "default".to_string(),
1120            BoardConfiguration {
1121                columns: vec!["todo".into(), "done".into()],
1122                hidden_columns: vec!["done".into()],
1123                ..BoardConfiguration::default()
1124            },
1125        );
1126        let store = crate::storage::Store::open(&config).unwrap();
1127        let board = BoardState::build(&store, config.default_config());
1128        assert_eq!(board.columns.len(), 1);
1129        assert_eq!(board.columns[0].name, "todo");
1130        assert_eq!(board.hidden.len(), 1);
1131        assert_eq!(board.hidden[0].name, "done");
1132        assert_eq!(board.total_cols(), 1);
1133    }
1134
1135    #[test]
1136    fn move_left_skips_hidden_column() {
1137        let mut board = BoardState {
1138            columns: vec![BoardColumn {
1139                name: "visible-a".into(),
1140                cards: vec![],
1141            }],
1142            hidden: vec![BoardColumn {
1143                name: "hidden-b".into(),
1144                cards: vec![],
1145            }],
1146            mismatch: BoardColumn {
1147                name: "Mismatched".into(),
1148                cards: vec![],
1149            },
1150            active_col: 0,
1151            selected: 0,
1152            filter: None,
1153            show_mismatch: true,
1154        };
1155        board.move_left();
1156        assert_eq!(board.active_col, 0, "single visible column wraps to itself");
1157    }
1158
1159    #[test]
1160    fn move_right_skips_hidden_column() {
1161        let mut board = BoardState {
1162            columns: vec![BoardColumn {
1163                name: "visible-a".into(),
1164                cards: vec![],
1165            }],
1166            hidden: vec![BoardColumn {
1167                name: "hidden-b".into(),
1168                cards: vec![],
1169            }],
1170            mismatch: BoardColumn {
1171                name: "Mismatched".into(),
1172                cards: vec![],
1173            },
1174            active_col: 0,
1175            selected: 0,
1176            filter: None,
1177            show_mismatch: true,
1178        };
1179        board.move_right();
1180        assert_eq!(board.active_col, 0, "single visible column wraps to itself");
1181    }
1182
1183    #[test]
1184    fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
1185        let mut board = BoardState {
1186            columns: vec![],
1187            hidden: vec![BoardColumn {
1188                name: "only-hidden".into(),
1189                cards: vec![],
1190            }],
1191            mismatch: BoardColumn {
1192                name: "Mismatched".into(),
1193                cards: vec![],
1194            },
1195            active_col: 0,
1196            selected: 0,
1197            filter: None,
1198            show_mismatch: false,
1199        };
1200        assert_eq!(board.total_cols(), 0);
1201        board.move_left();
1202        board.move_right();
1203        assert_eq!(board.active_col, 0, "zero-column board must not panic");
1204        let board2 = BoardState {
1205            columns: vec![],
1206            hidden: vec![],
1207            mismatch: BoardColumn {
1208                name: "Mismatched".into(),
1209                cards: vec![],
1210            },
1211            active_col: 0,
1212            selected: 0,
1213            filter: None,
1214            show_mismatch: false,
1215        };
1216        assert_eq!(board2.total_cols(), 0);
1217    }
1218
1219    fn write_issue_md(
1220        dir: &std::path::Path,
1221        name: &str,
1222        number: u32,
1223        title: &str,
1224        status: &str,
1225        created: &str,
1226    ) {
1227        let contents = format!(
1228            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
1229        );
1230        std::fs::write(dir.join(name), contents).unwrap();
1231    }
1232
1233    #[test]
1234    fn order_by_sort_desc_asc_none() {
1235        let mk = |created: &str| Card {
1236            number: 1,
1237            title: created.to_string(),
1238            priority: crate::model::Priority::Medium,
1239            labels: vec![],
1240            assignee: None,
1241            filename: "x.md".into(),
1242            commit: None,
1243            created: created.to_string(),
1244        };
1245        let dates =
1246            |cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
1247
1248        let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
1249        crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
1250        assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
1251
1252        let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
1253        crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
1254        assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
1255
1256        let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
1257        crate::config::OrderBy::None.sort(&mut cards);
1258        assert_eq!(
1259            dates(&cards),
1260            ["2026-03-01", "2026-01-01"],
1261            "none is a no-op"
1262        );
1263    }
1264
1265    #[test]
1266    fn build_orders_most_recent_first_by_default() {
1267        use crate::config::Config;
1268        use std::path::PathBuf;
1269        let tmp = tempfile::TempDir::new().unwrap();
1270        let dir = tmp.path().join("issues");
1271        std::fs::create_dir_all(&dir).unwrap();
1272        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
1273        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
1274        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
1275        let config = Config {
1276            issues_dir: PathBuf::from(&dir),
1277            ..Config::default()
1278        };
1279        let store = crate::storage::Store::open(&config).unwrap();
1280        let board = BoardState::build(&store, config.default_config());
1281        let titles: Vec<&str> = board
1282            .column_cards(0)
1283            .iter()
1284            .map(|c| c.title.as_str())
1285            .collect();
1286        assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
1287    }
1288
1289    #[test]
1290    fn build_applies_created_at_asc_and_none() {
1291        use crate::config::OrderBy;
1292        use std::path::PathBuf;
1293        let tmp = tempfile::TempDir::new().unwrap();
1294        let dir = tmp.path().join("issues");
1295        std::fs::create_dir_all(&dir).unwrap();
1296        write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
1297        write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
1298        write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
1299        let config = crate::config::Config {
1300            issues_dir: PathBuf::from(&dir),
1301            ..crate::config::Config::default()
1302        };
1303        let store = crate::storage::Store::open(&config).unwrap();
1304
1305        let mut cfg_asc = config.clone();
1306        cfg_asc
1307            .kanban
1308            .configurations
1309            .get_mut("default")
1310            .unwrap()
1311            .order_by = OrderBy::CreatedAtAsc;
1312        let board_asc = BoardState::build(&store, cfg_asc.default_config());
1313        let titles: Vec<&str> = board_asc
1314            .column_cards(0)
1315            .iter()
1316            .map(|c| c.title.as_str())
1317            .collect();
1318        assert_eq!(titles, ["old", "mid", "new"]);
1319
1320        let mut cfg_none = config;
1321        cfg_none
1322            .kanban
1323            .configurations
1324            .get_mut("default")
1325            .unwrap()
1326            .order_by = OrderBy::None;
1327        let board_none = BoardState::build(&store, cfg_none.default_config());
1328        let titles: Vec<&str> = board_none
1329            .column_cards(0)
1330            .iter()
1331            .map(|c| c.title.as_str())
1332            .collect();
1333        assert_eq!(
1334            titles,
1335            ["old", "mid", "new"],
1336            "none keeps the store's filename-ascending order"
1337        );
1338    }
1339
1340    #[test]
1341    fn build_filtered_applies_order_by() {
1342        use crate::model::Issue;
1343        use crate::search::{Corpus, StructuredFilter};
1344
1345        let to_issue = |number: u32, title: &str, created: &str| Issue {
1346            number,
1347            title: title.to_string(),
1348            status: crate::model::Status::new("todo"),
1349            labels: vec![],
1350            priority: crate::model::Priority::Medium,
1351            assignee: None,
1352            estimate: None,
1353            commit: None,
1354            gitlab_id: None,
1355            linear_id: None,
1356            mission: None,
1357            task: None,
1358            links: vec![],
1359            files: vec![],
1360            created: created.into(),
1361            updated: "2026-01-01".into(),
1362            filename: format!("2026-01-01-{number}.md"),
1363            body: String::new(),
1364        };
1365        let corpus = Corpus {
1366            issues: vec![
1367                to_issue(1, "old", "2026-01-01"),
1368                to_issue(2, "new", "2026-03-01"),
1369                to_issue(3, "mid", "2026-02-01"),
1370            ],
1371            texts: vec!["old".into(), "new".into(), "mid".into()],
1372        };
1373        let config = crate::config::Config::default();
1374        let board = BoardState::build_filtered(
1375            config.default_config(),
1376            &corpus,
1377            "",
1378            &StructuredFilter::default(),
1379        );
1380        let titles: Vec<&str> = board
1381            .column_cards(0)
1382            .iter()
1383            .map(|c| c.title.as_str())
1384            .collect();
1385        assert_eq!(
1386            titles,
1387            ["new", "mid", "old"],
1388            "default desc applies to filtered results too"
1389        );
1390    }
1391}