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 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 pub columns: Vec<BoardColumn>,
55 pub hidden: Vec<BoardColumn>,
59 pub mismatch: BoardColumn,
60 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 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 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 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 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 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 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 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#[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 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 let miss =
942 BoardState::build_filtered(board_config, &corpus, "999", &StructuredFilter::default());
943 assert!(miss.is_empty(), "no issue 999 => zero matches");
944
945 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 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 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 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}