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::BTreeMap;
7use std::collections::HashMap;
8use std::collections::HashSet;
9
10use crate::config::BoardConfiguration;
11use crate::model::{Issue, Priority};
12use crate::search::{Corpus, StructuredFilter};
13use crate::storage::Store;
14
15use super::widgets;
16
17#[derive(Debug, Clone)]
18struct GroupSection {
19 name: String,
20 start: usize,
21 end: usize,
22 done: usize,
23 total: usize,
24}
25
26const GROUP_OPEN_AFFORDANCE: &str = "Enter: open issue";
33
34fn health_badges(issues: &[Issue]) -> BTreeMap<u32, String> {
40 let policy = crate::health::HealthPolicy::v1();
41 let index = crate::health::HealthIndex::build(issues, &[], &[], &policy);
42 let mut out = BTreeMap::new();
43 for issue in issues {
44 let scope = crate::health::HealthScope::of_issue(issue.number);
45 let Some((state, recorded)) = index.state_of(issue.number) else {
46 continue;
47 };
48 let derived = crate::health::rollup(&index, &scope, &policy);
49 if recorded.is_none() && derived.contributions.is_empty() {
53 continue;
54 }
55 let current = state.as_str();
56 let badge = if derived.is_derived() && derived.state.as_str() != current {
57 format!("{current}\u{2192}{}", derived.state.as_str())
58 } else {
59 current.to_string()
60 };
61 out.insert(issue.number, badge);
62 }
63 out
64}
65
66#[derive(Debug, Clone)]
67pub struct Card {
68 pub number: u32,
69 pub title: String,
70 pub priority: Priority,
71 pub labels: Vec<String>,
72 pub assignee: Option<String>,
73 pub filename: String,
74 pub commit: Option<String>,
75 pub created: String,
76 pub due_date: Option<String>,
77 pub status: String,
78 pub category: Option<String>,
90 pub category_color: Option<crate::workflow::ColorHint>,
93 pub done: bool,
100 pub cycle: Option<String>,
101 pub milestone: Option<String>,
102 pub blocked: Option<String>,
108 pub children: Option<String>,
117 pub parent: Option<u32>,
122 pub depth: u8,
128 pub archived: bool,
135
136 pub health: Option<String>,
147 pub related: usize,
154 pub projects: Vec<String>,
167 pub epics: Vec<String>,
181}
182
183struct ProjectPartition {
212 memberships: HashMap<u32, Vec<String>>,
214 order: HashMap<String, usize>,
216}
217
218impl ProjectPartition {
219 fn build(config: &crate::config::Config, issues: &[Issue]) -> Self {
220 let index = crate::projects::ProjectIndex::build(config, issues);
226 let order: HashMap<String, usize> = index
227 .projects()
228 .iter()
229 .enumerate()
230 .map(|(position, project)| (project.id.to_string(), position))
231 .collect();
232 Self {
233 memberships: index.resolved_memberships(),
234 order,
235 }
236 }
237
238 fn memberships(&self, number: u32) -> Vec<String> {
240 self.memberships.get(&number).cloned().unwrap_or_default()
241 }
242
243 fn rank(&self, number: u32) -> usize {
248 match self.memberships.get(&number).map(Vec::as_slice) {
249 None | Some([]) => usize::MAX,
250 Some([only]) => self.order.get(only).copied().unwrap_or(usize::MAX),
251 Some(_) => usize::MAX - 1,
252 }
253 }
254}
255
256fn project_bucket_label(projects: &[String]) -> String {
264 crate::projects::project_bucket_label(projects)
267}
268
269struct EpicPartition {
298 memberships: HashMap<u32, Vec<String>>,
300 order: HashMap<String, usize>,
302}
303
304impl EpicPartition {
305 fn build(config: &crate::config::Config, issues: &[Issue]) -> Self {
306 let index = crate::epics::EpicIndex::build(config, issues);
312 let order: HashMap<String, usize> = index
313 .epics()
314 .iter()
315 .enumerate()
316 .map(|(position, epic)| (epic.id.to_string(), position))
317 .collect();
318 let memberships = index.memberships_by_issue();
319 Self { memberships, order }
320 }
321
322 fn memberships(&self, number: u32) -> Vec<String> {
324 self.memberships.get(&number).cloned().unwrap_or_default()
325 }
326
327 fn rank(&self, number: u32) -> usize {
331 match self.memberships.get(&number).map(Vec::as_slice) {
332 None | Some([]) => usize::MAX,
333 Some([only]) => self.order.get(only).copied().unwrap_or(usize::MAX),
334 Some(_) => usize::MAX - 1,
335 }
336 }
337}
338
339fn epic_bucket_label(ids: &[String]) -> String {
345 crate::search::epic_bucket_label(ids)
346}
347
348impl crate::config::OrderBy {
349 pub fn sort(&self, cards: &mut [Card]) {
355 match self {
356 crate::config::OrderBy::CreatedAtDesc => {
357 cards.sort_by(|a, b| b.created.cmp(&a.created))
358 }
359 crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
360 crate::config::OrderBy::None => {}
361 }
362 }
363}
364
365fn sort_by_default_order(board_config: &BoardConfiguration, cards: &mut [Card]) {
366 match board_config.order_by {
367 crate::config::OrderBy::CreatedAtDesc => {
368 cards.sort_by(|a, b| {
369 b.created
370 .cmp(&a.created)
371 .then_with(|| a.filename.cmp(&b.filename))
372 });
373 }
374 crate::config::OrderBy::CreatedAtAsc => {
375 cards.sort_by(|a, b| {
376 a.created
377 .cmp(&b.created)
378 .then_with(|| a.filename.cmp(&b.filename))
379 });
380 }
381 crate::config::OrderBy::None => {
382 cards.sort_by(|a, b| a.filename.cmp(&b.filename));
383 }
384 }
385}
386
387fn sort_cards(board_config: &BoardConfiguration, cards: &mut [Card]) {
388 if board_config.card_order.is_empty() {
389 sort_by_default_order(board_config, cards);
390 return;
391 }
392
393 let mut baseline = cards.to_vec();
394 sort_by_default_order(board_config, &mut baseline);
395
396 let manual_cards: Vec<Card> = board_config
397 .card_order
398 .iter()
399 .filter_map(|filename| {
400 baseline
401 .iter()
402 .find(|card| card.filename == *filename)
403 .cloned()
404 })
405 .collect();
406 let touched: HashSet<String> = manual_cards
407 .iter()
408 .map(|card| card.filename.clone())
409 .collect();
410 let mut manual_iter = manual_cards.into_iter();
411 let mut ordered = Vec::with_capacity(baseline.len());
412
413 for card in baseline {
414 if touched.contains(card.filename.as_str()) {
415 if let Some(manual_card) = manual_iter.next() {
416 ordered.push(manual_card);
417 } else {
418 ordered.push(card);
419 }
420 } else {
421 ordered.push(card);
422 }
423 }
424
425 cards.clone_from_slice(&ordered);
426}
427
428fn nest_children(cards: &mut Vec<Card>) {
445 let count = cards.len();
446 if count == 0 {
447 return;
448 }
449 let position: std::collections::HashMap<u32, usize> = cards
450 .iter()
451 .enumerate()
452 .map(|(i, card)| (card.number, i))
453 .collect();
454 let is_root = |index: usize| match cards[index].parent {
457 None => true,
458 Some(parent) => !position.contains_key(&parent),
459 };
460
461 let mut adopted: Vec<Vec<usize>> = vec![Vec::new(); count];
462 let mut top_level: Vec<usize> = Vec::with_capacity(count);
463 for (index, card) in cards.iter().enumerate() {
464 match card
465 .parent
466 .and_then(|parent| position.get(&parent).copied())
467 {
468 Some(parent) if is_root(parent) => adopted[parent].push(index),
469 _ => top_level.push(index),
470 }
471 }
472
473 let mut ordered: Vec<Card> = Vec::with_capacity(count);
474 for index in top_level {
475 let mut card = cards[index].clone();
476 card.depth = 0;
477 ordered.push(card);
478 for &child in &adopted[index] {
479 let mut child_card = cards[child].clone();
480 child_card.depth = 1;
481 ordered.push(child_card);
482 }
483 }
484 *cards = ordered;
485}
486
487pub(crate) fn sparse_card_order(board_config: &BoardConfiguration, cards: &[Card]) -> Vec<String> {
488 let mut baseline = cards.to_vec();
489 sort_by_default_order(board_config, &mut baseline);
490 baseline
491 .iter()
492 .zip(cards.iter())
493 .filter(|(baseline_card, current_card)| baseline_card.filename != current_card.filename)
494 .map(|(_, current_card)| current_card.filename.clone())
495 .collect()
496}
497
498#[derive(Debug, Clone)]
499pub struct BoardColumn {
500 pub name: String,
501 pub cards: Vec<Card>,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub enum HitTarget {
509 Card { col: usize, row: usize },
510 Column { col: usize },
511}
512
513pub type CapacitySummaries = std::collections::BTreeMap<String, String>;
520
521#[derive(Debug, Clone)]
522pub struct BoardState {
523 pub columns: Vec<BoardColumn>,
525 pub hidden: Vec<BoardColumn>,
529 pub mismatch: BoardColumn,
530 pub show_mismatch: bool,
535 pub active_col: usize,
536 pub selected: usize,
537 pub hover: Option<(usize, usize)>,
541 pub hover_col: Option<usize>,
546 pub filter: Option<String>,
547}
548
549pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
557 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
558
559 const ELLIPSIS: char = '\u{2026}';
560 if budget == 0 {
561 return String::new();
562 }
563 if UnicodeWidthStr::width(msg) <= budget {
564 return msg.to_string();
565 }
566
567 let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
571 let mut out = String::new();
572 let mut used = 0usize;
573 for ch in msg.chars() {
574 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
575 if used + w + marker_width > budget {
576 break;
577 }
578 out.push(ch);
579 used += w;
580 }
581 out.push(ELLIPSIS);
582 out
583}
584
585fn blocked_reasons(
590 issues: &[crate::model::Issue],
591 done_column: &str,
592) -> std::collections::BTreeMap<u32, String> {
593 let views: Vec<crate::relationships::IssueView<'_>> =
594 issues.iter().map(crate::model::Issue::issue_view).collect();
595 let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
596 issues
597 .iter()
598 .filter_map(|issue| {
599 crate::commands::blocked_note(&graph.blocked_state(issue.number))
600 .map(|note| (issue.number, note))
601 })
602 .collect()
603}
604
605fn child_markers(
612 issues: &[crate::model::Issue],
613 done_column: &str,
614) -> std::collections::BTreeMap<u32, String> {
615 let views: Vec<crate::relationships::IssueView<'_>> =
616 issues.iter().map(crate::model::Issue::issue_view).collect();
617 let index = crate::hierarchy::HierarchyIndex::build(&views);
618 issues
619 .iter()
620 .filter_map(|issue| {
621 let facts =
622 crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
623 facts.has_children().then(|| {
624 (
625 issue.number,
626 format!(
627 "{} {}/{}",
628 facts.rollup.direct_children,
629 facts.rollup.completed_children,
630 facts.rollup.progress.total
631 ),
632 )
633 })
634 })
635 .collect()
636}
637
638impl BoardState {
639 pub fn resolution_for(
655 store: &Store,
656 board_config: &BoardConfiguration,
657 ) -> crate::workflow::CategoryResolution {
658 let name = store
659 .config
660 .configs()
661 .find(|(_, board)| std::ptr::eq(*board, board_config))
662 .map(|(name, _)| name)
663 .unwrap_or("default");
664 crate::workflow::resolution(&store.config, board_config, name)
665 }
666
667 pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
668 Self::build_with(store, board_config, false, false)
669 }
670
671 pub fn build_with_archived(
680 store: &Store,
681 board_config: &BoardConfiguration,
682 include_archived: bool,
683 ) -> Self {
684 Self::build_with(store, board_config, include_archived, false)
685 }
686
687 pub fn build_with(
696 store: &Store,
697 board_config: &BoardConfiguration,
698 include_archived: bool,
699 show_children: bool,
700 ) -> Self {
701 let issues = if include_archived {
702 store.list_all()
703 } else {
704 store.list()
705 }
706 .unwrap_or_default();
707 let resolution = Self::resolution_for(store, board_config);
708 Self::build_from_issues(
709 &issues,
710 board_config,
711 &store.config,
712 &resolution,
713 show_children,
714 )
715 }
716
717 pub fn build_from_issues(
725 issues: &[Issue],
726 board_config: &BoardConfiguration,
727 config: &crate::config::Config,
728 resolution: &crate::workflow::CategoryResolution,
729 show_children: bool,
730 ) -> Self {
731 let done_column = crate::workflow::completion_column_or_default(board_config, config);
740 let done_column = done_column.as_str();
741 let blocked = blocked_reasons(issues, done_column);
742 let children = child_markers(issues, done_column);
743 let show_mismatch = board_config.show_mismatch;
744 let hidden_names: Vec<&str> = board_config
745 .hidden_columns
746 .iter()
747 .map(String::as_str)
748 .collect();
749 let mut column_map: Vec<Vec<Card>> =
750 board_config.columns.iter().map(|_| Vec::new()).collect();
751 let mut mismatch_cards = Vec::new();
752
753 let health_badges = health_badges(issues);
754 let projects = ProjectPartition::build(config, issues);
758 let epics = EpicPartition::build(config, issues);
762 for issue in issues {
763 let card = Card {
764 number: issue.number,
765 title: issue.title.clone(),
766 priority: issue.priority.clone(),
767 labels: issue.labels.clone(),
768 assignee: issue.assignee.clone(),
769 filename: issue.filename.clone(),
770 commit: issue.commit.clone(),
771 created: issue.created.clone(),
772 due_date: issue.due_date.clone(),
773 status: issue.status.display().to_string(),
774 category: crate::workflow::category_of(issue, resolution)
775 .map(|c| c.label().to_string()),
776 category_color: crate::workflow::category_of(issue, resolution)
777 .and_then(|c| c.color_hint()),
778 done: crate::workflow::is_completed(issue, resolution),
779 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
780 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
781 blocked: blocked.get(&issue.number).cloned(),
782 children: children.get(&issue.number).cloned(),
783 parent: issue.parent,
784 depth: 0,
785 archived: issue.archived,
786 related: issue.related_issues.len(),
787 health: health_badges.get(&issue.number).cloned(),
788 projects: projects.memberships(issue.number),
789 epics: epics.memberships(issue.number),
790 };
791 if let Some(idx) = board_config
792 .columns
793 .iter()
794 .position(|c| c == issue.status.display())
795 {
796 column_map[idx].push(card);
797 } else {
798 mismatch_cards.push(card);
799 }
800 }
801
802 for cards in column_map.iter_mut() {
803 sort_cards(board_config, cards);
804 }
805 sort_cards(board_config, &mut mismatch_cards);
806
807 let mut columns: Vec<BoardColumn> = Vec::new();
808 let mut hidden: Vec<BoardColumn> = Vec::new();
809 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
810 let col = BoardColumn {
811 name: name.clone(),
812 cards,
813 };
814 if hidden_names.contains(&name.as_str()) {
815 hidden.push(col);
816 } else {
817 columns.push(col);
818 }
819 }
820
821 let mismatch = BoardColumn {
822 name: "Mismatched".to_string(),
823 cards: mismatch_cards,
824 };
825
826 let mut state = BoardState {
827 columns,
828 hidden,
829 mismatch,
830 show_mismatch,
831 active_col: 0,
832 selected: 0,
833 filter: None,
834 hover: None,
835 hover_col: None,
836 };
837 state.apply_child_order(show_children);
838 state
839 }
840
841 fn apply_child_order(&mut self, show_children: bool) {
848 if !show_children {
849 return;
850 }
851 for column in &mut self.columns {
852 nest_children(&mut column.cards);
853 }
854 nest_children(&mut self.mismatch.cards);
855 }
856
857 pub fn build_filtered(
868 board_config: &BoardConfiguration,
869 config: &crate::config::Config,
870 corpus: &Corpus,
871 query: &str,
872 filter: &StructuredFilter,
873 resolution: &crate::workflow::CategoryResolution,
874 ) -> Self {
875 Self::build_filtered_with(
876 board_config,
877 config,
878 corpus,
879 query,
880 filter,
881 resolution,
882 false,
883 )
884 }
885
886 pub fn build_filtered_with(
893 board_config: &BoardConfiguration,
894 config: &crate::config::Config,
895 corpus: &Corpus,
896 query: &str,
897 filter: &StructuredFilter,
898 resolution: &crate::workflow::CategoryResolution,
899 show_children: bool,
900 ) -> Self {
901 let show_mismatch = board_config.show_mismatch;
902 let done_column = crate::workflow::completion_column_or_default(board_config, config);
905 let done_column = done_column.as_str();
906 let blocked = blocked_reasons(&corpus.issues, done_column);
909 let children = child_markers(&corpus.issues, done_column);
913 let filtering = !query.is_empty() || filter.has_active_criteria();
914 let sort_key = crate::search::control_token(query, "sort")
925 .or_else(|| crate::search::control_token(query, "group"));
926 let projects = ProjectPartition::build(config, &corpus.issues);
930 let epics = EpicPartition::build(config, &corpus.issues);
934 let number = crate::search::number_query(query);
940 let hidden_names: Vec<&str> = board_config
941 .hidden_columns
942 .iter()
943 .map(String::as_str)
944 .collect();
945 let mut column_map: Vec<Vec<Card>> =
946 board_config.columns.iter().map(|_| Vec::new()).collect();
947 let mut mismatch_cards = Vec::new();
948 let mut matched_issues: Vec<&crate::model::Issue> = Vec::new();
949
950 for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
951 let keep = if let Some(n) = number {
962 issue.number == n && filter.evaluate_issue(issue, text, resolution)
963 } else {
964 crate::search::matches_issue(query, filter, issue, text, resolution)
965 };
966 if !keep {
967 continue;
968 }
969 matched_issues.push(issue);
970 }
971
972 Self::sort_filtered_issues(&mut matched_issues, sort_key.as_deref(), &projects, &epics);
973
974 let health_badges = health_badges(&corpus.issues);
975 for issue in matched_issues {
976 let card = Card {
977 number: issue.number,
978 title: issue.title.clone(),
979 priority: issue.priority.clone(),
980 labels: issue.labels.clone(),
981 assignee: issue.assignee.clone(),
982 filename: issue.filename.clone(),
983 commit: issue.commit.clone(),
984 created: issue.created.clone(),
985 due_date: issue.due_date.clone(),
986 status: issue.status.display().to_string(),
987 category: crate::workflow::category_of(issue, resolution)
988 .map(|c| c.label().to_string()),
989 category_color: crate::workflow::category_of(issue, resolution)
990 .and_then(|c| c.color_hint()),
991 done: crate::workflow::is_completed(issue, resolution),
992 cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
993 milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
994 blocked: blocked.get(&issue.number).cloned(),
995 children: children.get(&issue.number).cloned(),
996 parent: issue.parent,
997 depth: 0,
998 archived: issue.archived,
999 related: issue.related_issues.len(),
1000 health: health_badges.get(&issue.number).cloned(),
1001 projects: projects.memberships(issue.number),
1002 epics: epics.memberships(issue.number),
1003 };
1004 if let Some(idx) = board_config
1005 .columns
1006 .iter()
1007 .position(|c| c == issue.status.display())
1008 {
1009 column_map[idx].push(card);
1010 } else {
1011 mismatch_cards.push(card);
1012 }
1013 }
1014
1015 if sort_key.is_none() {
1016 for cards in column_map.iter_mut() {
1017 sort_cards(board_config, cards);
1018 }
1019 sort_cards(board_config, &mut mismatch_cards);
1020 }
1021
1022 let mut columns: Vec<BoardColumn> = Vec::new();
1023 let mut hidden: Vec<BoardColumn> = Vec::new();
1024 for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
1025 let col = BoardColumn {
1026 name: name.clone(),
1027 cards,
1028 };
1029 if hidden_names.contains(&name.as_str()) {
1030 if filtering {
1031 columns.push(col);
1035 } else {
1036 hidden.push(col);
1037 }
1038 } else {
1039 columns.push(col);
1040 }
1041 }
1042
1043 let mut state = BoardState {
1044 columns,
1045 hidden,
1046 mismatch: BoardColumn {
1047 name: "Mismatched".to_string(),
1048 cards: mismatch_cards,
1049 },
1050 show_mismatch,
1051 active_col: 0,
1052 selected: 0,
1053 filter: Some(query.to_string()),
1054 hover: None,
1055 hover_col: None,
1056 };
1057 state.apply_child_order(show_children);
1058 state
1059 }
1060
1061 fn sort_filtered_issues(
1062 issues: &mut [&crate::model::Issue],
1063 sort: Option<&str>,
1064 projects: &ProjectPartition,
1065 epics: &EpicPartition,
1066 ) {
1067 match sort.unwrap_or("created") {
1068 "project" => issues.sort_by(|a, b| {
1074 projects
1075 .rank(a.number)
1076 .cmp(&projects.rank(b.number))
1077 .then(a.number.cmp(&b.number))
1078 }),
1079 "epic" => issues.sort_by(|a, b| {
1083 epics
1084 .rank(a.number)
1085 .cmp(&epics.rank(b.number))
1086 .then(a.number.cmp(&b.number))
1087 }),
1088 "due" => {
1089 issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number)))
1090 }
1091 "start" => issues.sort_by(|a, b| {
1092 a.start_date
1093 .cmp(&b.start_date)
1094 .then(a.number.cmp(&b.number))
1095 }),
1096 "reviewer" => {
1097 issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
1098 }
1099 "cycle" => issues.sort_by(|a, b| {
1100 a.cycles
1101 .as_ref()
1102 .and_then(|v| v.first())
1103 .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
1104 .then(a.number.cmp(&b.number))
1105 }),
1106 "milestone" => issues.sort_by(|a, b| {
1107 a.milestones
1108 .as_ref()
1109 .and_then(|v| v.first())
1110 .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
1111 .then(a.number.cmp(&b.number))
1112 }),
1113 "status" => issues.sort_by(|a, b| {
1114 a.status
1115 .display()
1116 .cmp(b.status.display())
1117 .then(a.number.cmp(&b.number))
1118 }),
1119 "priority" => issues.sort_by(|a, b| {
1120 a.priority
1121 .to_string()
1122 .cmp(&b.priority.to_string())
1123 .then(a.number.cmp(&b.number))
1124 }),
1125 _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
1126 }
1127 }
1128
1129 pub fn is_empty(&self) -> bool {
1131 self.columns.iter().all(|c| c.cards.is_empty())
1132 && (!self.show_mismatch || self.mismatch.cards.is_empty())
1133 }
1134
1135 pub fn total_cols(&self) -> usize {
1136 if self.show_mismatch {
1137 self.columns.len() + 1
1138 } else {
1139 self.columns.len()
1140 }
1141 }
1142
1143 pub fn hit_test(
1152 &self,
1153 area: Rect,
1154 display: BoardDisplay,
1155 status: bool,
1156 x: u16,
1157 y: u16,
1158 ) -> Option<HitTarget> {
1159 if area.height < 2 || area.width < 4 || self.total_cols() == 0 {
1160 return None;
1161 }
1162 let board_height = area.height.saturating_sub(u16::from(status));
1163 if y >= area.y + board_height {
1164 return None;
1165 }
1166 let board_area = Rect {
1167 height: board_height,
1168 ..area
1169 };
1170
1171 let rects = compute_column_rects(board_area, self.total_cols());
1172 let (col, col_rect) = rects
1173 .iter()
1174 .enumerate()
1175 .find(|(_, r)| r.contains(Position::new(x, y)))?;
1176
1177 let cards: &[Card] = if col < self.columns.len() {
1178 &self.columns[col].cards
1179 } else {
1180 &self.mismatch.cards
1181 };
1182
1183 let group_mode = self
1184 .filter
1185 .as_deref()
1186 .and_then(|q| crate::search::control_token(q, "group"));
1187
1188 let mut items: Vec<Option<usize>> = Vec::new();
1191 if let Some(mode) = group_mode.as_deref() {
1192 if let Some(sections) = Self::group_sections(cards, Some(mode)) {
1193 for section in sections {
1194 items.push(None);
1195 for (j, card) in cards
1196 .iter()
1197 .enumerate()
1198 .take(section.end)
1199 .skip(section.start)
1200 {
1201 let count = widgets::card_line_count(
1202 card,
1203 col_rect.width as usize,
1204 display.wrap_titles,
1205 display.title_lines,
1206 );
1207 for _ in 0..count {
1208 items.push(Some(j));
1209 }
1210 }
1211 }
1212 }
1213 } else {
1214 for (j, card) in cards.iter().enumerate() {
1215 let count = widgets::card_line_count(
1216 card,
1217 col_rect.width as usize,
1218 display.wrap_titles,
1219 display.title_lines,
1220 );
1221 for _ in 0..count {
1222 items.push(Some(j));
1223 }
1224 }
1225 }
1226
1227 let has_footer = BoardState::group_footer(cards, group_mode.as_deref()).is_some();
1228 let list_height = col_rect.height.saturating_sub(u16::from(has_footer));
1229 let content_top = col_rect.y + 1;
1231 let content_end = col_rect.y + list_height.saturating_sub(1);
1232 if y >= content_top && y < content_end {
1233 let offset = usize::from(y - content_top);
1234 if let Some(Some(row)) = items.get(offset) {
1235 return Some(HitTarget::Card { col, row: *row });
1236 }
1237 }
1238 Some(HitTarget::Column { col })
1239 }
1240
1241 pub fn real_cols(&self) -> usize {
1245 self.columns.len()
1246 }
1247
1248 pub fn column_name(&self, idx: usize) -> &str {
1249 if idx < self.columns.len() {
1250 &self.columns[idx].name
1251 } else if idx == self.columns.len() && self.show_mismatch {
1252 &self.mismatch.name
1253 } else {
1254 "Unknown"
1255 }
1256 }
1257
1258 pub fn active_column_name(&self) -> &str {
1263 let visible = self.columns.len();
1264 if self.active_col < visible {
1265 &self.columns[self.active_col].name
1266 } else {
1267 let hidden_idx = self.active_col - visible;
1268 if hidden_idx < self.hidden.len() {
1269 &self.hidden[hidden_idx].name
1270 } else {
1271 "Unknown"
1272 }
1273 }
1274 }
1275
1276 pub fn column_cards(&self, idx: usize) -> &[Card] {
1277 if idx < self.columns.len() {
1278 &self.columns[idx].cards
1279 } else if idx == self.columns.len() && self.show_mismatch {
1280 &self.mismatch.cards
1281 } else {
1282 &[]
1283 }
1284 }
1285
1286 pub fn get_selected_card(&self) -> Option<&Card> {
1287 self.column_cards(self.active_col).get(self.selected)
1288 }
1289
1290 pub fn move_left(&mut self) {
1291 let total = self.real_cols();
1292 self.active_col = if total == 0 {
1293 0
1294 } else if self.active_col >= total {
1295 total - 1
1296 } else {
1297 (self.active_col + total - 1) % total
1298 };
1299 self.clamp_selection();
1300 }
1301
1302 pub fn move_right(&mut self) {
1303 let total = self.real_cols();
1304 self.active_col = if total == 0 || self.active_col >= total {
1305 0
1306 } else {
1307 (self.active_col + 1) % total
1308 };
1309 self.clamp_selection();
1310 }
1311
1312 pub fn move_up(&mut self) {
1313 if self.selected > 0 {
1314 self.selected -= 1;
1315 }
1316 }
1317
1318 pub fn move_down(&mut self) {
1319 let cards = self.column_cards(self.active_col);
1320 if self.selected + 1 < cards.len() {
1321 self.selected += 1;
1322 }
1323 }
1324
1325 pub fn move_selection_by(&mut self, cards: i32) {
1332 let len = self.column_cards(self.active_col).len();
1333 if len == 0 {
1334 self.selected = 0;
1335 return;
1336 }
1337 let next = i32::try_from(self.selected)
1338 .unwrap_or(i32::MAX)
1339 .saturating_add(cards);
1340 self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
1341 }
1342
1343 pub fn clamp_selection(&mut self) {
1344 let cards = self.column_cards(self.active_col);
1345 if cards.is_empty() {
1346 self.selected = 0;
1347 } else if self.selected >= cards.len() {
1348 self.selected = cards.len() - 1;
1349 }
1350 }
1351
1352 pub fn select_column(&mut self, col: usize) {
1353 let total = self.real_cols();
1354 if total > 0 {
1355 self.active_col = col % total;
1356 }
1357 self.clamp_selection();
1358 }
1359
1360 fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
1361 let sections = Self::group_sections(cards, mode)?;
1362 let text = sections
1363 .into_iter()
1364 .map(|section| {
1365 let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
1366 format!("{} {}/{} {pct}%", section.name, section.done, section.total)
1367 })
1368 .collect::<Vec<_>>()
1369 .join(" | ");
1370 Some(format!("{text} · {GROUP_OPEN_AFFORDANCE}"))
1375 }
1376
1377 fn card_group(card: &Card, key: &str) -> String {
1378 match key {
1379 "cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
1380 "milestone" => card
1381 .milestone
1382 .clone()
1383 .unwrap_or_else(|| "(none)".to_string()),
1384 "project" => project_bucket_label(&card.projects),
1389 "epic" => epic_bucket_label(&card.epics),
1393 _ => "(none)".to_string(),
1394 }
1395 }
1396
1397 fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
1398 let key =
1399 mode.filter(|m| *m == "cycle" || *m == "milestone" || *m == "project" || *m == "epic")?;
1400 if cards.is_empty() {
1401 return None;
1402 }
1403 let mut sections = Vec::new();
1404 let mut current = String::new();
1405 let mut start = 0usize;
1406 let mut done = 0usize;
1407 let mut total = 0usize;
1408 for (idx, card) in cards.iter().enumerate() {
1409 let group = Self::card_group(card, key);
1410 if idx == 0 {
1411 current = group;
1412 } else if group != current {
1413 sections.push(GroupSection {
1414 name: std::mem::take(&mut current),
1415 start,
1416 end: idx,
1417 done,
1418 total,
1419 });
1420 current = group;
1421 start = idx;
1422 done = 0;
1423 total = 0;
1424 }
1425 total += 1;
1426 if card.done {
1427 done += 1;
1428 }
1429 }
1430 sections.push(GroupSection {
1431 name: current,
1432 start,
1433 end: cards.len(),
1434 done,
1435 total,
1436 });
1437 Some(sections)
1438 }
1439}
1440
1441#[derive(Debug, Clone, Copy)]
1444pub struct BoardDisplay {
1445 pub wrap_titles: bool,
1446 pub title_lines: usize,
1447}
1448
1449impl Default for BoardDisplay {
1450 fn default() -> Self {
1451 BoardDisplay {
1452 wrap_titles: true,
1453 title_lines: 3,
1454 }
1455 }
1456}
1457
1458impl From<&crate::config::BoardConfiguration> for BoardDisplay {
1459 fn from(config: &crate::config::BoardConfiguration) -> Self {
1460 BoardDisplay {
1461 wrap_titles: config.wrap_titles,
1462 title_lines: config.title_lines,
1463 }
1464 }
1465}
1466
1467pub fn compute_column_rects(area: Rect, total_cols: usize) -> Vec<Rect> {
1471 if total_cols == 0 {
1472 return Vec::new();
1473 }
1474 Layout::default()
1475 .direction(Direction::Horizontal)
1476 .constraints(vec![Constraint::Ratio(1, total_cols as u32); total_cols])
1477 .split(area)
1478 .to_vec()
1479}
1480
1481#[allow(clippy::too_many_arguments)]
1485pub fn render(
1486 board: &BoardState,
1487 f: &mut Frame,
1488 area: Rect,
1489 status_msg: &Option<String>,
1490 drag_preview_col: Option<usize>,
1491 display: BoardDisplay,
1492 theme: &crate::ui::color::Theme,
1493 wip: Option<&crate::wip::WipSnapshot>,
1494 capacity: Option<&CapacitySummaries>,
1495) -> usize {
1496 let mut page_cards = 0usize;
1503
1504 if area.height < 2 || area.width < 4 {
1505 return 0;
1506 }
1507
1508 let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
1509 let board_height = area.height.saturating_sub(status_height);
1510 let board_area = Rect {
1511 height: board_height,
1512 ..area
1513 };
1514 let status_area = Rect {
1515 y: area.y + board_height,
1516 height: status_height,
1517 ..area
1518 };
1519
1520 let total = board.total_cols();
1521 if total == 0 {
1522 return 0;
1523 }
1524
1525 let chunks = compute_column_rects(board_area, total);
1526
1527 for (i, chunk) in chunks.iter().enumerate() {
1528 let col = if i < board.columns.len() {
1529 &board.columns[i]
1530 } else {
1531 &board.mismatch
1532 };
1533 let group_mode = board
1534 .filter
1535 .as_deref()
1536 .and_then(|q| crate::search::control_token(q, "group"));
1537
1538 let is_active = i == board.active_col;
1539 let is_mismatch_col = i == board.columns.len();
1540 let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
1541 let hovered_col = board.hover_col;
1544
1545 let mut selected_row: Option<usize> = None;
1546 let items: Vec<ListItem> = if let Some(mode) = group_mode.as_deref() {
1547 if let Some(sections) = BoardState::group_sections(&col.cards, Some(mode)) {
1548 let mut items = Vec::new();
1549 for section in sections {
1550 let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
1551 let capacity_cell = (mode == "cycle")
1558 .then(|| capacity.and_then(|cells| cells.get(§ion.name)))
1559 .flatten();
1560 let header = match capacity_cell {
1561 Some(cell) => format!(
1562 "-- {mode}: {} ({}/{} done, {pct}%) · {cell} --",
1563 section.name, section.done, section.total
1564 ),
1565 None => format!(
1566 "-- {mode}: {} ({}/{} done, {pct}%) --",
1567 section.name, section.done, section.total
1568 ),
1569 };
1570 items.push(ListItem::new(Line::from(Span::styled(
1571 header,
1572 Style::default()
1573 .fg(Color::Yellow)
1574 .add_modifier(Modifier::BOLD),
1575 ))));
1576 for j in section.start..section.end {
1577 let card = &col.cards[j];
1578 let lines = widgets::card_lines_themed(
1579 card,
1580 chunk.width as usize,
1581 display.wrap_titles,
1582 display.title_lines,
1583 theme,
1584 );
1585 let style = if is_active && j == board.selected {
1586 selected_row = Some(items.len());
1587 Style::default()
1588 .bg(Color::DarkGray)
1589 .add_modifier(Modifier::BOLD)
1590 } else if board.hover == Some((i, j)) {
1591 if drag_preview_col == Some(i) {
1593 Style::default()
1594 .fg(Color::Yellow)
1595 .add_modifier(Modifier::BOLD)
1596 } else {
1597 Style::default().fg(Color::Cyan)
1598 }
1599 } else if is_mismatch_highlight {
1600 Style::default().fg(Color::Red)
1601 } else {
1602 Style::default()
1603 };
1604 items.push(ListItem::new(lines).style(style));
1605 }
1606 }
1607 items
1608 } else {
1609 Vec::new()
1610 }
1611 } else {
1612 col.cards
1613 .iter()
1614 .enumerate()
1615 .map(|(j, card)| {
1616 let lines = widgets::card_lines_themed(
1617 card,
1618 chunk.width as usize,
1619 display.wrap_titles,
1620 display.title_lines,
1621 theme,
1622 );
1623 let style = if is_active && j == board.selected {
1624 selected_row = Some(j);
1625 Style::default()
1626 .bg(Color::DarkGray)
1627 .add_modifier(Modifier::BOLD)
1628 } else if board.hover == Some((i, j)) {
1629 if drag_preview_col == Some(i) {
1630 Style::default()
1631 .fg(Color::Yellow)
1632 .add_modifier(Modifier::BOLD)
1633 } else {
1634 Style::default().fg(Color::Cyan)
1635 }
1636 } else if is_mismatch_highlight {
1637 Style::default().fg(Color::Red)
1638 } else {
1639 Style::default()
1640 };
1641 ListItem::new(lines).style(style)
1642 })
1643 .collect()
1644 };
1645
1646 let title = match wip
1651 .and_then(|snapshot| snapshot.column(&col.name))
1652 .filter(|usage| usage.limit.is_some())
1653 {
1654 Some(usage) => format!("{} ({})", col.name, usage.cell()),
1655 None => format!("{} ({})", col.name, col.cards.len()),
1656 };
1657 let border_style = if drag_preview_col == Some(i) {
1658 Style::default().fg(Color::Yellow)
1659 } else if is_active {
1660 Style::default().fg(Color::Cyan)
1661 } else if is_mismatch_highlight {
1662 Style::default().fg(Color::Red)
1663 } else if hovered_col == Some(i) {
1664 Style::default()
1667 .fg(Color::Blue)
1668 .add_modifier(Modifier::BOLD)
1669 } else {
1670 Style::default()
1671 };
1672
1673 let block = Block::default()
1674 .borders(Borders::ALL)
1675 .title(title)
1676 .border_style(border_style);
1677
1678 let col_chunks = if BoardState::group_footer(&col.cards, group_mode.as_deref()).is_some() {
1679 Layout::default()
1680 .direction(Direction::Vertical)
1681 .constraints([Constraint::Min(1), Constraint::Length(1)])
1682 .split(*chunk)
1683 } else {
1684 Layout::default()
1685 .direction(Direction::Vertical)
1686 .constraints([Constraint::Min(1), Constraint::Length(0)])
1687 .split(*chunk)
1688 };
1689
1690 let list = List::new(items).block(block);
1691
1692 if is_active {
1693 page_cards = cards_per_screen(
1696 &col.cards,
1697 chunk.width as usize,
1698 col_chunks[0].height.saturating_sub(2),
1699 display,
1700 );
1701 let mut state = ListState::default();
1702 if let Some(row) = selected_row {
1703 state.select(Some(row));
1704 }
1705 f.render_stateful_widget(list, col_chunks[0], &mut state);
1706 } else {
1707 f.render_widget(list, col_chunks[0]);
1708 }
1709 if let Some(footer) = BoardState::group_footer(&col.cards, group_mode.as_deref()) {
1710 let para = Paragraph::new(Line::from(Span::styled(
1711 footer,
1712 Style::default().fg(Color::DarkGray),
1713 )));
1714 f.render_widget(para, col_chunks[1]);
1715 }
1716 }
1717
1718 if let Some(msg) = status_msg {
1719 let budget = (status_area.width as usize).saturating_sub(1);
1736 let text = truncate_to_cells(msg, budget);
1737 let para =
1738 Paragraph::new(Line::from(Span::raw(text))).style(Style::default().fg(Color::Yellow));
1739 f.render_widget(para, status_area);
1740 }
1741
1742 page_cards
1743}
1744
1745fn cards_per_screen(cards: &[Card], width: usize, rows: u16, display: BoardDisplay) -> usize {
1756 if cards.is_empty() {
1757 return 0;
1758 }
1759 let mut used: u16 = 0;
1760 let mut fits = 0usize;
1761 for card in cards {
1762 let height = u16::try_from(
1763 widgets::card_lines(card, width, display.wrap_titles, display.title_lines).len(),
1764 )
1765 .unwrap_or(u16::MAX);
1766 if fits > 0 && used.saturating_add(height) > rows {
1767 break;
1768 }
1769 used = used.saturating_add(height);
1770 fits += 1;
1771 if used >= rows {
1772 break;
1773 }
1774 }
1775 fits.max(1)
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780 use super::*;
1781 use crate::config::Config;
1782
1783 #[test]
1784 fn compute_column_rects_splits_into_equal_columns() {
1785 let area = Rect::new(5, 7, 90, 30);
1786 let rects = compute_column_rects(area, 3);
1787 assert_eq!(rects.len(), 3);
1788 assert_eq!((rects[0].x, rects[0].width), (5, 30));
1789 assert_eq!((rects[1].x, rects[1].width), (35, 30));
1790 assert_eq!((rects[2].x, rects[2].width), (65, 30));
1791 assert_eq!((rects[0].y, rects[0].height), (7, 30));
1792 assert_eq!((rects[2].y, rects[2].height), (7, 30));
1793 }
1794
1795 #[test]
1796 fn compute_column_rects_zero_columns_is_empty() {
1797 assert_eq!(
1798 compute_column_rects(Rect::new(0, 0, 10, 10), 0),
1799 [] as [ratatui::prelude::Rect; 0]
1800 );
1801 }
1802
1803 #[test]
1804 fn card_line_count_matches_rendered_card_heights() {
1805 use ratatui::Terminal;
1806 use ratatui::backend::TestBackend;
1807
1808 let wrapped = "A deliberately long card title that cannot fit on a single line inside the narrow column and must wrap over several rows";
1809 let cards = vec![
1810 Card {
1811 health: None,
1812 number: 1,
1813 title: "Short".into(),
1814 priority: crate::model::Priority::Medium,
1815 labels: vec![],
1816 assignee: None,
1817 filename: "1.md".into(),
1818 commit: None,
1819 created: "2026-01-01".into(),
1820 due_date: None,
1821 status: "todo".into(),
1822 category: None,
1823 category_color: None,
1824 done: false,
1825 cycle: None,
1826 milestone: None,
1827 blocked: None,
1828 children: None,
1829 parent: None,
1830 depth: 0,
1831 archived: false,
1832 related: 0,
1833 projects: vec![],
1834 epics: Vec::new(),
1835 },
1836 Card {
1837 health: None,
1838 number: 2,
1839 title: wrapped.into(),
1840 priority: crate::model::Priority::Medium,
1841 labels: vec![],
1842 assignee: None,
1843 filename: "2.md".into(),
1844 commit: None,
1845 created: "2026-01-01".into(),
1846 due_date: None,
1847 status: "todo".into(),
1848 category: None,
1849 category_color: None,
1850 done: false,
1851 cycle: None,
1852 milestone: None,
1853 blocked: None,
1854 children: None,
1855 parent: None,
1856 depth: 0,
1857 archived: false,
1858 related: 0,
1859 projects: vec![],
1860 epics: Vec::new(),
1861 },
1862 Card {
1863 health: None,
1864 number: 3,
1865 title: "Another long title card that also wraps".into(),
1866 priority: crate::model::Priority::Medium,
1867 labels: vec![],
1868 assignee: None,
1869 filename: "3.md".into(),
1870 commit: None,
1871 created: "2026-01-01".into(),
1872 due_date: None,
1873 status: "todo".into(),
1874 category: None,
1875 category_color: None,
1876 done: false,
1877 cycle: None,
1878 milestone: None,
1879 blocked: None,
1880 children: None,
1881 parent: None,
1882 depth: 0,
1883 archived: false,
1884 related: 0,
1885 projects: vec![],
1886 epics: Vec::new(),
1887 },
1888 ];
1889 let board = BoardState {
1890 columns: vec![BoardColumn {
1891 name: "todo".into(),
1892 cards,
1893 }],
1894 hidden: vec![],
1895 mismatch: BoardColumn {
1896 name: "Mismatched".into(),
1897 cards: vec![],
1898 },
1899 active_col: 0,
1900 selected: 0,
1901 filter: None,
1902 hover: None,
1903 hover_col: None,
1904 show_mismatch: false,
1905 };
1906 let display = BoardDisplay {
1907 wrap_titles: true,
1908 title_lines: 3,
1909 };
1910 let width = 80u16;
1911 let height = 30u16;
1912 let backend = TestBackend::new(width, height);
1913 let mut terminal = Terminal::new(backend).unwrap();
1914 terminal
1915 .draw(|f| {
1916 render(
1917 &board,
1918 f,
1919 f.area(),
1920 &None,
1921 None,
1922 display,
1923 &crate::ui::color::Theme::default(),
1924 None,
1925 None,
1926 );
1927 })
1928 .unwrap();
1929 let buffer = terminal.backend().buffer();
1930
1931 let col_width = compute_column_rects(Rect::new(0, 0, width, height), 1)[0].width as usize;
1932
1933 let mut starts: Vec<(u32, u16)> = Vec::new();
1934 for y in 0..height {
1935 let line: String = (1..width.min(24))
1936 .map(|x| buffer[(x, y)].symbol())
1937 .collect();
1938 for card in &board.columns[0].cards {
1939 if line.starts_with(&format!("#{} ", card.number))
1940 && !starts.iter().any(|(n, _)| *n == card.number)
1941 {
1942 starts.push((card.number, y));
1943 break;
1944 }
1945 }
1946 }
1947 starts.sort_by_key(|(_, y)| *y);
1948 assert_eq!(
1949 starts.len(),
1950 3,
1951 "every card found in the buffer: {starts:?}"
1952 );
1953 for (idx, (number, y)) in starts.iter().enumerate() {
1954 let span: u16 = if idx + 1 < starts.len() {
1955 starts[idx + 1].1 - y
1956 } else {
1957 u16::try_from(
1958 (*y..height)
1959 .filter(|&rw| {
1960 rw != height - 1
1961 && (1..(width - 1)).any(|x| buffer[(x, rw)].symbol() != " ")
1962 })
1963 .count(),
1964 )
1965 .unwrap()
1966 };
1967 let card = board.columns[0]
1968 .cards
1969 .iter()
1970 .find(|c| c.number == *number)
1971 .expect("card on the board");
1972 let expected =
1973 widgets::card_line_count(card, col_width, display.wrap_titles, display.title_lines);
1974 assert_eq!(
1975 span, expected,
1976 "rendered height of card #{number} must match card_line_count"
1977 );
1978 }
1979 }
1980
1981 #[test]
1982 fn hit_test_cards_and_column_fallback() {
1983 let tall = "A deliberately long card title that cannot fit on a single line inside the narrow column and wraps over several rows";
1984 let cards = vec![
1985 Card {
1986 health: None,
1987 number: 1,
1988 title: "one".into(),
1989 priority: Priority::Medium,
1990 labels: vec![],
1991 assignee: None,
1992 filename: "1.md".into(),
1993 commit: None,
1994 created: "2026-01-01".into(),
1995 due_date: None,
1996 status: "todo".into(),
1997 category: None,
1998 category_color: None,
1999 done: false,
2000 cycle: None,
2001 milestone: None,
2002 blocked: None,
2003 children: None,
2004 parent: None,
2005 depth: 0,
2006 archived: false,
2007 related: 0,
2008 projects: vec![],
2009 epics: Vec::new(),
2010 },
2011 Card {
2012 health: None,
2013 number: 2,
2014 title: tall.into(),
2015 priority: Priority::Medium,
2016 labels: vec![],
2017 assignee: None,
2018 filename: "2.md".into(),
2019 commit: None,
2020 created: "2026-01-02".into(),
2021 due_date: None,
2022 status: "todo".into(),
2023 category: None,
2024 category_color: None,
2025 done: false,
2026 cycle: None,
2027 milestone: None,
2028 blocked: None,
2029 children: None,
2030 parent: None,
2031 depth: 0,
2032 archived: false,
2033 related: 0,
2034 projects: vec![],
2035 epics: Vec::new(),
2036 },
2037 ];
2038 let board = BoardState {
2039 columns: vec![
2040 BoardColumn {
2041 name: "todo".into(),
2042 cards,
2043 },
2044 BoardColumn {
2045 name: "done".into(),
2046 cards: vec![Card {
2047 health: None,
2048 number: 3,
2049 title: "three".into(),
2050 priority: Priority::Medium,
2051 labels: vec![],
2052 assignee: None,
2053 filename: "3.md".into(),
2054 commit: None,
2055 created: "2026-01-03".into(),
2056 due_date: None,
2057 status: "done".into(),
2058 category: None,
2059 category_color: None,
2060 done: true,
2061 cycle: None,
2062 milestone: None,
2063 blocked: None,
2064 children: None,
2065 parent: None,
2066 depth: 0,
2067 archived: false,
2068 related: 0,
2069 projects: vec![],
2070 epics: Vec::new(),
2071 }],
2072 },
2073 ],
2074 hidden: vec![],
2075 mismatch: BoardColumn {
2076 name: "Mismatched".into(),
2077 cards: vec![],
2078 },
2079 active_col: 0,
2080 selected: 0,
2081 filter: None,
2082 hover: None,
2083 hover_col: None,
2084 show_mismatch: false,
2085 };
2086 let display = BoardDisplay::default();
2087 let area = Rect::new(0, 0, 80, 30);
2088 let widths = compute_column_rects(area, 2);
2089 let col0 = widths[0].width as usize;
2090 let card1_height = widgets::card_line_count(&board.columns[0].cards[0], col0, true, 3);
2091
2092 assert_eq!(
2094 board.hit_test(area, display, false, 3, 1),
2095 Some(HitTarget::Card { col: 0, row: 0 })
2096 );
2097 assert_eq!(
2099 board.hit_test(area, display, false, 3, 1 + card1_height),
2100 Some(HitTarget::Card { col: 0, row: 1 })
2101 );
2102 assert_eq!(
2104 board.hit_test(area, display, false, 3, 10),
2105 Some(HitTarget::Column { col: 0 })
2106 );
2107 assert_eq!(
2109 board.hit_test(area, display, false, 0, 5),
2110 Some(HitTarget::Column { col: 0 })
2111 );
2112 assert_eq!(
2114 board.hit_test(area, display, false, 55, 0),
2115 Some(HitTarget::Column { col: 1 })
2116 );
2117 assert_eq!(
2118 board.hit_test(area, display, false, 55, 1),
2119 Some(HitTarget::Card { col: 1, row: 0 })
2120 );
2121 assert_eq!(
2123 board.hit_test(area, display, false, 3, 29),
2124 Some(HitTarget::Column { col: 0 })
2125 );
2126 }
2127
2128 #[test]
2129 fn hit_test_outside_area_is_none() {
2130 let board = BoardState {
2131 columns: vec![BoardColumn {
2132 name: "todo".into(),
2133 cards: vec![Card {
2134 health: None,
2135 number: 1,
2136 title: "one".into(),
2137 priority: Priority::Medium,
2138 labels: vec![],
2139 assignee: None,
2140 filename: "1.md".into(),
2141 commit: None,
2142 created: "2026-01-01".into(),
2143 due_date: None,
2144 status: "todo".into(),
2145 category: None,
2146 category_color: None,
2147 done: false,
2148 cycle: None,
2149 milestone: None,
2150 blocked: None,
2151 children: None,
2152 parent: None,
2153 depth: 0,
2154 archived: false,
2155 related: 0,
2156 projects: vec![],
2157 epics: Vec::new(),
2158 }],
2159 }],
2160 hidden: vec![],
2161 mismatch: BoardColumn {
2162 name: "Mismatched".into(),
2163 cards: vec![],
2164 },
2165 active_col: 0,
2166 selected: 0,
2167 filter: None,
2168 hover: None,
2169 hover_col: None,
2170 show_mismatch: false,
2171 };
2172 let display = BoardDisplay::default();
2173 let area = Rect::new(0, 5, 40, 12);
2174 assert_eq!(board.hit_test(area, display, false, 3, 3), None);
2176 assert_eq!(
2178 board.hit_test(area, display, false, 3, 6),
2179 Some(HitTarget::Card { col: 0, row: 0 })
2180 );
2181 assert_eq!(board.hit_test(area, display, false, 44, 7), None);
2183 }
2184
2185 #[test]
2186 fn hit_test_respects_status_row() {
2187 let board = BoardState {
2188 columns: vec![BoardColumn {
2189 name: "todo".into(),
2190 cards: vec![Card {
2191 health: None,
2192 number: 1,
2193 title: "one".into(),
2194 priority: Priority::Medium,
2195 labels: vec![],
2196 assignee: None,
2197 filename: "1.md".into(),
2198 commit: None,
2199 created: "2026-01-01".into(),
2200 due_date: None,
2201 status: "todo".into(),
2202 category: None,
2203 category_color: None,
2204 done: false,
2205 cycle: None,
2206 milestone: None,
2207 blocked: None,
2208 children: None,
2209 parent: None,
2210 depth: 0,
2211 archived: false,
2212 related: 0,
2213 projects: vec![],
2214 epics: Vec::new(),
2215 }],
2216 }],
2217 hidden: vec![],
2218 mismatch: BoardColumn {
2219 name: "Mismatched".into(),
2220 cards: vec![],
2221 },
2222 active_col: 0,
2223 selected: 0,
2224 filter: None,
2225 hover: None,
2226 hover_col: None,
2227 show_mismatch: false,
2228 };
2229 let display = BoardDisplay::default();
2230 let area = Rect::new(0, 0, 80, 30);
2231 assert_eq!(board.hit_test(area, display, true, 3, 29), None);
2234 assert_eq!(
2235 board.hit_test(area, display, true, 3, 1),
2236 Some(HitTarget::Card { col: 0, row: 0 })
2237 );
2238 }
2239
2240 #[test]
2241 fn hit_test_group_header_is_not_a_card() {
2242 let cycle_card = |number: u32, cycle: &str| Card {
2243 health: None,
2244 number,
2245 title: format!("task {number}"),
2246 priority: Priority::Medium,
2247 labels: vec![],
2248 assignee: None,
2249 filename: format!("{number}.md"),
2250 commit: None,
2251 created: format!("2026-01-{number:02}"),
2252 due_date: None,
2253 status: "todo".into(),
2254 category: None,
2255 category_color: None,
2256 done: false,
2257 cycle: Some(cycle.into()),
2258 milestone: None,
2259 blocked: None,
2260 children: None,
2261 parent: None,
2262 depth: 0,
2263 archived: false,
2264 related: 0,
2265 projects: vec![],
2266 epics: Vec::new(),
2267 };
2268 let board = BoardState {
2269 columns: vec![
2270 BoardColumn {
2271 name: "todo".into(),
2272 cards: vec![cycle_card(1, "alpha"), cycle_card(2, "beta")],
2273 },
2274 BoardColumn {
2275 name: "done".into(),
2276 cards: vec![],
2277 },
2278 ],
2279 hidden: vec![],
2280 mismatch: BoardColumn {
2281 name: "Mismatched".into(),
2282 cards: vec![],
2283 },
2284 active_col: 0,
2285 selected: 0,
2286 filter: Some("group:cycle".into()),
2287 hover: None,
2288 hover_col: None,
2289 show_mismatch: false,
2290 };
2291 let display = BoardDisplay::default();
2292 let area = Rect::new(0, 0, 80, 30);
2293 let col0 = area.width / 2;
2294 let first_height =
2295 widgets::card_line_count(&board.columns[0].cards[0], col0 as usize, true, 3);
2296
2297 assert_eq!(
2299 board.hit_test(area, display, false, 5, 1),
2300 Some(HitTarget::Column { col: 0 })
2301 );
2302 assert_eq!(
2304 board.hit_test(area, display, false, 5, 2),
2305 Some(HitTarget::Card { col: 0, row: 0 })
2306 );
2307 assert_eq!(
2309 board.hit_test(area, display, false, 5, 2 + first_height),
2310 Some(HitTarget::Column { col: 0 })
2311 );
2312 assert_eq!(
2313 board.hit_test(area, display, false, 5, 3 + first_height),
2314 Some(HitTarget::Card { col: 0, row: 1 })
2315 );
2316 }
2317
2318 #[test]
2319 fn hit_test_mismatch_column_resolves() {
2320 let board = BoardState {
2321 columns: vec![BoardColumn {
2322 name: "todo".into(),
2323 cards: vec![],
2324 }],
2325 hidden: vec![],
2326 mismatch: BoardColumn {
2327 name: "Mismatched".into(),
2328 cards: vec![Card {
2329 health: None,
2330 number: 9,
2331 title: "oddball".into(),
2332 priority: Priority::Medium,
2333 labels: vec![],
2334 assignee: None,
2335 filename: "9.md".into(),
2336 commit: None,
2337 created: "2026-01-09".into(),
2338 due_date: None,
2339 status: "weird".into(),
2340 category: None,
2341 category_color: None,
2342 done: false,
2343 cycle: None,
2344 milestone: None,
2345 blocked: None,
2346 children: None,
2347 parent: None,
2348 depth: 0,
2349 archived: false,
2350 related: 0,
2351 projects: vec![],
2352 epics: Vec::new(),
2353 }],
2354 },
2355 active_col: 0,
2356 selected: 0,
2357 filter: None,
2358 hover: None,
2359 hover_col: None,
2360 show_mismatch: true,
2361 };
2362 let display = BoardDisplay::default();
2363 let area = Rect::new(0, 0, 90, 30);
2364 let mismatch_col = board.columns.len();
2365 assert_eq!(
2367 board.hit_test(area, display, false, 70, 1),
2368 Some(HitTarget::Card {
2369 col: mismatch_col,
2370 row: 0
2371 })
2372 );
2373 }
2374
2375 #[test]
2376 fn render_highlights_hovered_card_without_selecting() {
2377 use ratatui::Terminal;
2378 use ratatui::backend::TestBackend;
2379 let board = BoardState {
2380 columns: vec![BoardColumn {
2381 name: "todo".into(),
2382 cards: vec![
2383 Card {
2384 health: None,
2385 number: 1,
2386 title: "Alpha".into(),
2387 priority: Priority::Medium,
2388 labels: vec![],
2389 assignee: None,
2390 filename: "1.md".into(),
2391 commit: None,
2392 created: "2026-01-01".into(),
2393 due_date: None,
2394 status: "todo".into(),
2395 category: None,
2396 category_color: None,
2397 done: false,
2398 cycle: None,
2399 milestone: None,
2400 blocked: None,
2401 children: None,
2402 parent: None,
2403 depth: 0,
2404 archived: false,
2405 related: 0,
2406 projects: vec![],
2407 epics: Vec::new(),
2408 },
2409 Card {
2410 health: None,
2411 number: 2,
2412 title: "Beta".into(),
2413 priority: Priority::Medium,
2414 labels: vec![],
2415 assignee: None,
2416 filename: "2.md".into(),
2417 commit: None,
2418 created: "2026-01-02".into(),
2419 due_date: None,
2420 status: "todo".into(),
2421 category: None,
2422 category_color: None,
2423 done: false,
2424 cycle: None,
2425 milestone: None,
2426 blocked: None,
2427 children: None,
2428 parent: None,
2429 depth: 0,
2430 archived: false,
2431 related: 0,
2432 projects: vec![],
2433 epics: Vec::new(),
2434 },
2435 ],
2436 }],
2437 hidden: vec![],
2438 mismatch: BoardColumn {
2439 name: "Mismatched".into(),
2440 cards: vec![],
2441 },
2442 show_mismatch: false,
2443 active_col: 0,
2444 selected: 0,
2445 hover: Some((0, 1)),
2446 hover_col: Some(0),
2447 filter: None,
2448 };
2449 let display = BoardDisplay::default();
2450 let backend = TestBackend::new(80, 30);
2451 let mut terminal = Terminal::new(backend).unwrap();
2452 terminal
2453 .draw(|f| {
2454 render(
2455 &board,
2456 f,
2457 f.area(),
2458 &None,
2459 None,
2460 display,
2461 &crate::ui::color::Theme::default(),
2462 None,
2463 None,
2464 );
2465 })
2466 .unwrap();
2467 let buffer = terminal.backend().buffer();
2468
2469 let mut rows = [None, None];
2470 for y in 0..30u16 {
2471 let line: String = (1..12).map(|x| buffer[(x, y)].symbol()).collect();
2472 if line.starts_with("#1") {
2473 rows[0] = Some(y);
2474 }
2475 if line.starts_with("#2") {
2476 rows[1] = Some(y);
2477 }
2478 }
2479 let y1 = rows[0].expect("first card rendered");
2480 let y2 = rows[1].expect("second card rendered");
2481
2482 assert_eq!(buffer[(3, y1)].style().bg, Some(Color::DarkGray));
2484 assert_eq!(buffer[(3, y2)].style().fg, Some(Color::Cyan));
2486 assert_eq!((board.active_col, board.selected), (0, 0));
2488 }
2489
2490 #[test]
2491 fn render_highlights_the_hovered_cards_column() {
2492 use ratatui::Terminal;
2493 use ratatui::backend::TestBackend;
2494
2495 let card = |number: u32, title: &str, status: &str| Card {
2496 health: None,
2497 number,
2498 title: title.into(),
2499 priority: Priority::Medium,
2500 labels: vec![],
2501 assignee: None,
2502 filename: format!("{number}.md"),
2503 commit: None,
2504 created: "2026-01-01".into(),
2505 due_date: None,
2506 status: status.into(),
2507 category: None,
2508 category_color: None,
2509 done: false,
2510 cycle: None,
2511 milestone: None,
2512 blocked: None,
2513 children: None,
2514 parent: None,
2515 depth: 0,
2516 archived: false,
2517 related: 0,
2518 projects: vec![],
2519 epics: Vec::new(),
2520 };
2521 let board = BoardState {
2522 columns: vec![
2523 BoardColumn {
2524 name: "todo".into(),
2525 cards: vec![card(1, "Alpha", "todo")],
2526 },
2527 BoardColumn {
2528 name: "doing".into(),
2529 cards: vec![card(2, "Beta", "doing")],
2530 },
2531 ],
2532 hidden: vec![],
2533 mismatch: BoardColumn {
2534 name: "Mismatched".into(),
2535 cards: vec![],
2536 },
2537 show_mismatch: false,
2538 active_col: 0,
2539 selected: 0,
2540 hover: Some((1, 0)),
2542 hover_col: Some(1),
2543 filter: None,
2544 };
2545 let display = BoardDisplay::default();
2546 let area = Rect::new(0, 0, 80, 30);
2547 let backend = TestBackend::new(80, 30);
2548 let mut terminal = Terminal::new(backend).unwrap();
2549 terminal
2550 .draw(|f| {
2551 render(
2552 &board,
2553 f,
2554 f.area(),
2555 &None,
2556 None,
2557 display,
2558 &crate::ui::color::Theme::default(),
2559 None,
2560 None,
2561 );
2562 })
2563 .unwrap();
2564 let buffer = terminal.backend().buffer();
2565
2566 let chunks = compute_column_rects(area, board.total_cols());
2567 let hovered = chunks[1];
2568 let active = chunks[0];
2569 assert_eq!(
2570 buffer[(hovered.x, hovered.y)].style().fg,
2571 Some(Color::Blue),
2572 "the hovered column is outlined"
2573 );
2574 assert_eq!(
2575 buffer[(active.x, active.y)].style().fg,
2576 Some(Color::Cyan),
2577 "the active column keeps its own colour"
2578 );
2579 }
2580
2581 #[test]
2582 fn render_marks_an_empty_hovered_column() {
2583 use ratatui::Terminal;
2584 use ratatui::backend::TestBackend;
2585
2586 let board = BoardState {
2587 columns: vec![
2588 BoardColumn {
2589 name: "todo".into(),
2590 cards: vec![],
2591 },
2592 BoardColumn {
2593 name: "doing".into(),
2594 cards: vec![],
2595 },
2596 ],
2597 hidden: vec![],
2598 mismatch: BoardColumn {
2599 name: "Mismatched".into(),
2600 cards: vec![],
2601 },
2602 show_mismatch: false,
2603 active_col: 0,
2604 selected: 0,
2605 hover: None,
2606 hover_col: Some(1),
2608 filter: None,
2609 };
2610 let display = BoardDisplay::default();
2611 let area = Rect::new(0, 0, 80, 30);
2612 let backend = TestBackend::new(80, 30);
2613 let mut terminal = Terminal::new(backend).unwrap();
2614 terminal
2615 .draw(|f| {
2616 render(
2617 &board,
2618 f,
2619 f.area(),
2620 &None,
2621 None,
2622 display,
2623 &crate::ui::color::Theme::default(),
2624 None,
2625 None,
2626 );
2627 })
2628 .unwrap();
2629 let buffer = terminal.backend().buffer();
2630
2631 let chunks = compute_column_rects(area, board.total_cols());
2632 assert_eq!(
2633 buffer[(chunks[1].x, chunks[1].y)].style().fg,
2634 Some(Color::Blue),
2635 "an empty column with no cards is still outlined when hovered"
2636 );
2637 assert_eq!(
2638 buffer[(chunks[0].x, chunks[0].y)].style().fg,
2639 Some(Color::Cyan),
2640 "the active column keeps its own colour"
2641 );
2642 }
2643
2644 #[test]
2645 fn total_cols_includes_mismatch() {
2646 let board = BoardState {
2647 columns: vec![
2648 BoardColumn {
2649 name: "a".into(),
2650 cards: vec![],
2651 },
2652 BoardColumn {
2653 name: "b".into(),
2654 cards: vec![],
2655 },
2656 ],
2657 mismatch: BoardColumn {
2658 name: "Mismatched".into(),
2659 cards: vec![],
2660 },
2661 active_col: 0,
2662 selected: 0,
2663 filter: None,
2664 hover: None,
2665 hover_col: None,
2666 hidden: vec![],
2667 show_mismatch: true,
2668 };
2669 assert_eq!(board.total_cols(), 3);
2670 }
2671
2672 #[test]
2673 fn column_name_mismatch_index() {
2674 let board = BoardState {
2675 columns: vec![BoardColumn {
2676 name: "todo".into(),
2677 cards: vec![],
2678 }],
2679 mismatch: BoardColumn {
2680 name: "Mismatched".into(),
2681 cards: vec![],
2682 },
2683 active_col: 0,
2684 selected: 0,
2685 filter: None,
2686 hover: None,
2687 hover_col: None,
2688 hidden: vec![],
2689 show_mismatch: true,
2690 };
2691 assert_eq!(board.column_name(0), "todo");
2692 assert_eq!(board.column_name(1), "Mismatched");
2693 }
2694
2695 #[test]
2696 fn move_left_wraps_around() {
2697 let mut board = BoardState {
2698 columns: vec![
2699 BoardColumn {
2700 name: "a".into(),
2701 cards: vec![],
2702 },
2703 BoardColumn {
2704 name: "b".into(),
2705 cards: vec![],
2706 },
2707 ],
2708 hidden: vec![],
2709 mismatch: BoardColumn {
2710 name: "Mismatched".into(),
2711 cards: vec![],
2712 },
2713 active_col: 0,
2714 selected: 0,
2715 filter: None,
2716 hover: None,
2717 hover_col: None,
2718 show_mismatch: true,
2719 };
2720 board.move_left();
2721 assert_eq!(board.active_col, 1);
2722 }
2723
2724 #[test]
2725 fn move_right_wraps_around() {
2726 let mut board = BoardState {
2727 columns: vec![
2728 BoardColumn {
2729 name: "a".into(),
2730 cards: vec![],
2731 },
2732 BoardColumn {
2733 name: "b".into(),
2734 cards: vec![],
2735 },
2736 ],
2737 mismatch: BoardColumn {
2738 name: "Mismatched".into(),
2739 cards: vec![],
2740 },
2741 active_col: 2,
2742 selected: 0,
2743 filter: None,
2744 hover: None,
2745 hover_col: None,
2746 hidden: vec![],
2747 show_mismatch: true,
2748 };
2749 board.move_right();
2750 assert_eq!(board.active_col, 0);
2751 }
2752
2753 #[test]
2754 fn clamp_selection_empty_column() {
2755 let mut board = BoardState {
2756 columns: vec![BoardColumn {
2757 name: "a".into(),
2758 cards: vec![],
2759 }],
2760 mismatch: BoardColumn {
2761 name: "Mismatched".into(),
2762 cards: vec![],
2763 },
2764 active_col: 0,
2765 selected: 5,
2766 filter: None,
2767 hover: None,
2768 hover_col: None,
2769 hidden: vec![],
2770 show_mismatch: true,
2771 };
2772 board.clamp_selection();
2773 assert_eq!(board.selected, 0);
2774 }
2775
2776 #[test]
2777 fn clamp_selection_single_card() {
2778 let mut board = BoardState {
2779 columns: vec![BoardColumn {
2780 name: "a".into(),
2781 cards: vec![Card {
2782 health: None,
2783 number: 1,
2784 title: "X".into(),
2785 priority: crate::model::Priority::Medium,
2786 labels: vec![],
2787 assignee: None,
2788 filename: "x.md".into(),
2789 commit: None,
2790 created: "2026-01-01".into(),
2791 due_date: None,
2792 status: "todo".into(),
2793 category: None,
2794 category_color: None,
2795 done: false,
2796 cycle: None,
2797 milestone: None,
2798 blocked: None,
2799 children: None,
2800 parent: None,
2801 depth: 0,
2802 archived: false,
2803 related: 0,
2804 projects: vec![],
2805 epics: Vec::new(),
2806 }],
2807 }],
2808 mismatch: BoardColumn {
2809 name: "Mismatched".into(),
2810 cards: vec![],
2811 },
2812 active_col: 0,
2813 selected: 5,
2814 filter: None,
2815 hover: None,
2816 hover_col: None,
2817 hidden: vec![],
2818 show_mismatch: true,
2819 };
2820 board.clamp_selection();
2821 assert_eq!(board.selected, 0);
2822 }
2823
2824 #[test]
2825 fn move_up_clamps_at_top() {
2826 let mut board = BoardState {
2827 columns: vec![BoardColumn {
2828 name: "a".into(),
2829 cards: vec![Card {
2830 health: None,
2831 number: 1,
2832 title: "X".into(),
2833 priority: crate::model::Priority::Medium,
2834 labels: vec![],
2835 assignee: None,
2836 filename: "x.md".into(),
2837 commit: None,
2838 created: "2026-01-01".into(),
2839 due_date: None,
2840 status: "todo".into(),
2841 category: None,
2842 category_color: None,
2843 done: false,
2844 cycle: None,
2845 milestone: None,
2846 blocked: None,
2847 children: None,
2848 parent: None,
2849 depth: 0,
2850 archived: false,
2851 related: 0,
2852 projects: vec![],
2853 epics: Vec::new(),
2854 }],
2855 }],
2856 mismatch: BoardColumn {
2857 name: "Mismatched".into(),
2858 cards: vec![],
2859 },
2860 active_col: 0,
2861 selected: 0,
2862 filter: None,
2863 hover: None,
2864 hover_col: None,
2865 hidden: vec![],
2866 show_mismatch: true,
2867 };
2868 board.move_up();
2869 assert_eq!(board.selected, 0);
2870 }
2871
2872 #[test]
2873 fn move_down_clamps_at_bottom() {
2874 let mut board = BoardState {
2875 columns: vec![BoardColumn {
2876 name: "a".into(),
2877 cards: vec![Card {
2878 health: None,
2879 number: 1,
2880 title: "X".into(),
2881 priority: crate::model::Priority::Medium,
2882 labels: vec![],
2883 assignee: None,
2884 filename: "x.md".into(),
2885 commit: None,
2886 created: "2026-01-01".into(),
2887 due_date: None,
2888 status: "todo".into(),
2889 category: None,
2890 category_color: None,
2891 done: false,
2892 cycle: None,
2893 milestone: None,
2894 blocked: None,
2895 children: None,
2896 parent: None,
2897 depth: 0,
2898 archived: false,
2899 related: 0,
2900 projects: vec![],
2901 epics: Vec::new(),
2902 }],
2903 }],
2904 mismatch: BoardColumn {
2905 name: "Mismatched".into(),
2906 cards: vec![],
2907 },
2908 active_col: 0,
2909 selected: 0,
2910 filter: None,
2911 hover: None,
2912 hover_col: None,
2913 hidden: vec![],
2914 show_mismatch: true,
2915 };
2916 board.move_down();
2917 assert_eq!(board.selected, 0);
2918 }
2919
2920 #[test]
2921 fn render_smoke_does_not_panic_on_tiny_frame() {
2922 use ratatui::Terminal;
2923 use ratatui::backend::TestBackend;
2924
2925 let backend = TestBackend::new(5, 3);
2926 let mut terminal = Terminal::new(backend).unwrap();
2927 let board = BoardState {
2928 columns: vec![],
2929 mismatch: BoardColumn {
2930 name: "Mismatched".into(),
2931 cards: vec![],
2932 },
2933 active_col: 0,
2934 selected: 0,
2935 filter: None,
2936 hover: None,
2937 hover_col: None,
2938 hidden: vec![],
2939 show_mismatch: true,
2940 };
2941 terminal
2942 .draw(|f| {
2943 render(
2944 &board,
2945 f,
2946 f.area(),
2947 &None,
2948 None,
2949 BoardDisplay::default(),
2950 &crate::ui::color::Theme::default(),
2951 None,
2952 None,
2953 );
2954 })
2955 .unwrap();
2956 }
2957
2958 #[test]
2959 fn render_smoke_full_board() {
2960 use ratatui::Terminal;
2961 use ratatui::backend::TestBackend;
2962
2963 let backend = TestBackend::new(80, 24);
2964 let mut terminal = Terminal::new(backend).unwrap();
2965 let board = BoardState {
2966 columns: vec![
2967 BoardColumn {
2968 name: "todo".into(),
2969 cards: vec![],
2970 },
2971 BoardColumn {
2972 name: "done".into(),
2973 cards: vec![],
2974 },
2975 ],
2976 mismatch: BoardColumn {
2977 name: "Mismatched".into(),
2978 cards: vec![],
2979 },
2980 active_col: 0,
2981 selected: 0,
2982 filter: None,
2983 hover: None,
2984 hover_col: None,
2985 hidden: vec![],
2986 show_mismatch: true,
2987 };
2988 terminal
2989 .draw(|f| {
2990 render(
2991 &board,
2992 f,
2993 f.area(),
2994 &Some("test msg".into()),
2995 None,
2996 BoardDisplay::default(),
2997 &crate::ui::color::Theme::default(),
2998 None,
2999 None,
3000 );
3001 })
3002 .unwrap();
3003 }
3004
3005 #[test]
3006 fn render_selected_multiline_card_highlights_all_wrapped_lines() {
3007 use ratatui::Terminal;
3008 use ratatui::backend::TestBackend;
3009
3010 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";
3011 let card = crate::ui::board::Card {
3012 health: None,
3013 number: 7,
3014 title: title.into(),
3015 priority: crate::model::Priority::Medium,
3016 labels: vec![],
3017 assignee: None,
3018 filename: "x.md".into(),
3019 commit: None,
3020 created: "2026-01-01".into(),
3021 due_date: None,
3022 status: "todo".into(),
3023 category: None,
3024 category_color: None,
3025 done: false,
3026 cycle: None,
3027 milestone: None,
3028 blocked: None,
3029 children: None,
3030 parent: None,
3031 depth: 0,
3032 archived: false,
3033 related: 0,
3034 projects: vec![],
3035 epics: Vec::new(),
3036 };
3037 let backend = TestBackend::new(80, 24);
3038 let mut terminal = Terminal::new(backend).unwrap();
3039 let board = BoardState {
3040 columns: vec![BoardColumn {
3041 name: "todo".into(),
3042 cards: vec![card],
3043 }],
3044 mismatch: BoardColumn {
3045 name: "Mismatched".into(),
3046 cards: vec![],
3047 },
3048 active_col: 0,
3049 selected: 0,
3050 filter: None,
3051 hover: None,
3052 hover_col: None,
3053 hidden: vec![],
3054 show_mismatch: false,
3055 };
3056 let display = BoardDisplay {
3057 wrap_titles: true,
3058 title_lines: 3,
3059 };
3060 terminal
3061 .draw(|f| {
3062 render(
3063 &board,
3064 f,
3065 f.area(),
3066 &None,
3067 None,
3068 display,
3069 &crate::ui::color::Theme::default(),
3070 None,
3071 None,
3072 );
3073 })
3074 .unwrap();
3075 let buffer = terminal.backend().buffer();
3076
3077 let mut highlighted = 0;
3081 for y in 1..buffer.area().height - 1 {
3082 let cell = &buffer[(2, y)];
3083 if cell.bg != Color::DarkGray {
3084 break;
3085 }
3086 assert!(
3087 cell.modifier.contains(Modifier::BOLD),
3088 "highlighted continuation line must stay bold: {cell:?}"
3089 );
3090 highlighted += 1;
3091 }
3092 assert!(
3093 highlighted >= 2,
3094 "wrapped card highlights every line, got {highlighted}"
3095 );
3096 assert!(
3097 highlighted <= display.title_lines,
3098 "highlight never exceeds the line limit: {highlighted}"
3099 );
3100 }
3101
3102 #[test]
3103 fn render_grouped_mode_shows_section_headers() {
3104 use ratatui::Terminal;
3105 use ratatui::backend::TestBackend;
3106
3107 let backend = TestBackend::new(80, 24);
3108 let mut terminal = Terminal::new(backend).unwrap();
3109 let board = BoardState {
3110 columns: vec![BoardColumn {
3111 name: "todo".into(),
3112 cards: vec![
3113 Card {
3114 health: None,
3115 number: 1,
3116 title: "A".into(),
3117 priority: crate::model::Priority::Medium,
3118 labels: vec![],
3119 assignee: None,
3120 filename: "a.md".into(),
3121 commit: None,
3122 created: "2026-01-01".into(),
3123 due_date: None,
3124 status: "todo".into(),
3125 category: None,
3126 category_color: None,
3127 done: false,
3128 cycle: Some("Cycle 1".into()),
3129 milestone: None,
3130 blocked: None,
3131 children: None,
3132 parent: None,
3133 depth: 0,
3134 archived: false,
3135 related: 0,
3136 projects: vec![],
3137 epics: Vec::new(),
3138 },
3139 Card {
3140 health: None,
3141 number: 2,
3142 title: "B".into(),
3143 priority: crate::model::Priority::Medium,
3144 labels: vec![],
3145 assignee: None,
3146 filename: "b.md".into(),
3147 commit: None,
3148 created: "2026-01-01".into(),
3149 due_date: None,
3150 status: "todo".into(),
3151 category: None,
3152 category_color: None,
3153 done: true,
3154 cycle: Some("Cycle 1".into()),
3155 milestone: None,
3156 blocked: None,
3157 children: None,
3158 parent: None,
3159 depth: 0,
3160 archived: false,
3161 related: 0,
3162 projects: vec![],
3163 epics: Vec::new(),
3164 },
3165 Card {
3166 health: None,
3167 number: 3,
3168 title: "C".into(),
3169 priority: crate::model::Priority::Medium,
3170 labels: vec![],
3171 assignee: None,
3172 filename: "c.md".into(),
3173 commit: None,
3174 created: "2026-01-01".into(),
3175 due_date: None,
3176 status: "todo".into(),
3177 category: None,
3178 category_color: None,
3179 done: false,
3180 cycle: Some("Cycle 2".into()),
3181 milestone: None,
3182 blocked: None,
3183 children: None,
3184 parent: None,
3185 depth: 0,
3186 archived: false,
3187 related: 0,
3188 projects: vec![],
3189 epics: Vec::new(),
3190 },
3191 ],
3192 }],
3193 mismatch: BoardColumn {
3194 name: "Mismatched".into(),
3195 cards: vec![],
3196 },
3197 active_col: 0,
3198 selected: 0,
3199 filter: Some("group:cycle".into()),
3200 hover: None,
3201 hover_col: None,
3202 hidden: vec![],
3203 show_mismatch: false,
3204 };
3205 terminal
3206 .draw(|f| {
3207 render(
3208 &board,
3209 f,
3210 f.area(),
3211 &None,
3212 None,
3213 BoardDisplay::default(),
3214 &crate::ui::color::Theme::default(),
3215 None,
3216 None,
3217 );
3218 })
3219 .unwrap();
3220
3221 let buffer = terminal.backend().buffer();
3222 let mut lines = Vec::new();
3223 for y in 0..buffer.area.height {
3224 let line = (0..buffer.area.width)
3225 .map(|x| buffer[(x, y)].symbol())
3226 .collect::<String>();
3227 lines.push(line);
3228 }
3229 let rendered = lines.join("\n");
3230 assert!(rendered.contains("-- cycle: Cycle 1 (1/2 done, 50%) --"));
3231 assert!(rendered.contains("-- cycle: Cycle 2 (0/1 done, 0%) --"));
3232 }
3233
3234 #[test]
3235 fn build_filtered_keeps_only_fuzzy_matches() {
3236 use crate::model::Issue;
3237 use crate::search::Corpus;
3238
3239 let to_issue = |number: u32, title: &str| Issue {
3240 projects: None,
3241 id: None,
3242 number,
3243 title: title.to_string(),
3244 status: crate::model::Status::new("todo"),
3245 labels: vec![],
3246 priority: crate::model::Priority::Medium,
3247 assignee: None,
3248 estimate: None,
3249 commit: None,
3250 gitlab_id: None,
3251 linear_id: None,
3252 mission: None,
3253 task: None,
3254 relationships: vec![],
3255 related_issues: vec![],
3256 files: vec![],
3257 cycles: None,
3258 milestones: None,
3259 reviewer: None,
3260 start_date: None,
3261 due_date: None,
3262 created: "2026-01-01".into(),
3263 type_: None,
3264 updated: "2026-01-01".into(),
3265 filename: format!("2026-01-01-{number}.md"),
3266 body: String::new(),
3267 history: crate::model::History::new(),
3268 has_retired_links_key: false,
3269 time_entries: vec![],
3270 parent: None,
3271 series: None,
3272 health: None,
3273 epics: None,
3274 extra_frontmatter: std::collections::BTreeMap::new(),
3275 archived: false,
3276 entered_at: None,
3277 source: None,
3278 };
3279 let corpus = Corpus::with_texts(
3280 vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
3281 vec!["Fix login bug".to_string(), "Polish exports".to_string()],
3282 );
3283 let config = Config::default();
3284 let (_, resolution) = crate::workflow::active_resolution(&config);
3285 let board_config = config.default_config();
3286
3287 let board = BoardState::build_filtered(
3288 board_config,
3289 &config,
3290 &corpus,
3291 "log",
3292 &StructuredFilter::default(),
3293 &resolution,
3294 );
3295 assert_eq!(board.columns.len(), board_config.columns.len());
3296 assert_eq!(board.column_cards(0).len(), 1);
3297 assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
3298 assert_eq!(board.filter.as_deref(), Some("log"));
3299
3300 let none = BoardState::build_filtered(
3301 board_config,
3302 &config,
3303 &corpus,
3304 "zzw",
3305 &StructuredFilter::default(),
3306 &resolution,
3307 );
3308 assert!(none.is_empty());
3309
3310 let empty = BoardState::build_filtered(
3311 board_config,
3312 &config,
3313 &corpus,
3314 "",
3315 &StructuredFilter::default(),
3316 &resolution,
3317 );
3318 assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
3319 assert!(empty.filter.is_some());
3320 }
3321
3322 #[test]
3323 fn build_filtered_group_cycle_keeps_original_titles() {
3324 use crate::model::Issue;
3325 use crate::search::Corpus;
3326
3327 let issue = |number: u32, title: &str, status: &str, cycles: &[&str]| Issue {
3328 projects: None,
3329 id: None,
3330 number,
3331 title: title.to_string(),
3332 status: crate::model::Status::new(status),
3333 labels: vec![],
3334 priority: crate::model::Priority::Medium,
3335 assignee: None,
3336 estimate: None,
3337 commit: None,
3338 gitlab_id: None,
3339 linear_id: None,
3340 mission: None,
3341 task: None,
3342 relationships: vec![],
3343 related_issues: vec![],
3344 files: vec![],
3345 cycles: Some(cycles.iter().map(|c| (*c).to_string()).collect()),
3346 milestones: None,
3347 reviewer: None,
3348 start_date: None,
3349 due_date: None,
3350 created: "2026-01-01".into(),
3351 type_: None,
3352 updated: "2026-01-01".into(),
3353 filename: format!("2026-01-01-{number}.md"),
3354 body: String::new(),
3355 history: crate::model::History::new(),
3356 has_retired_links_key: false,
3357 time_entries: vec![],
3358 parent: None,
3359 series: None,
3360 health: None,
3361 epics: None,
3362 extra_frontmatter: std::collections::BTreeMap::new(),
3363 archived: false,
3364 entered_at: None,
3365 source: None,
3366 };
3367
3368 let corpus = Corpus::with_texts(
3369 vec![
3370 issue(1, "A", "todo", &["Cycle 1"]),
3371 issue(2, "B", "done", &["Cycle 1"]),
3372 issue(3, "C", "todo", &["Cycle 2"]),
3373 ],
3374 vec![
3375 "cycle: Cycle 1\nstatus: todo".to_string(),
3376 "cycle: Cycle 1\nstatus: done".to_string(),
3377 "cycle: Cycle 2\nstatus: todo".to_string(),
3378 ],
3379 );
3380
3381 let config = Config::default();
3382 let (_, resolution) = crate::workflow::active_resolution(&config);
3383 let board = BoardState::build_filtered(
3384 config.default_config(),
3385 &config,
3386 &corpus,
3387 "group:cycle",
3388 &StructuredFilter::default(),
3389 &resolution,
3390 );
3391 let titles: Vec<String> = board
3392 .columns
3393 .iter()
3394 .flat_map(|col| col.cards.iter().map(|c| c.title.clone()))
3395 .collect();
3396 assert!(titles.iter().any(|t| t == "A"));
3397 assert!(titles.iter().any(|t| t == "B"));
3398 assert!(titles.iter().any(|t| t == "C"));
3399 }
3400
3401 fn project_fixture() -> (Config, crate::search::Corpus) {
3404 use crate::config::ProjectConfiguration;
3405 use crate::model::Issue;
3406 use crate::search::Corpus;
3407
3408 let issue = |number: u32, title: &str, projects: &[&str]| Issue {
3409 projects: if projects.is_empty() {
3410 None
3411 } else {
3412 Some(projects.iter().map(|p| (*p).to_string()).collect())
3413 },
3414 id: None,
3415 number,
3416 title: title.to_string(),
3417 status: crate::model::Status::new("todo"),
3418 labels: vec![],
3419 priority: crate::model::Priority::Medium,
3420 assignee: None,
3421 estimate: None,
3422 commit: None,
3423 gitlab_id: None,
3424 linear_id: None,
3425 mission: None,
3426 task: None,
3427 relationships: vec![],
3428 related_issues: vec![],
3429 files: vec![],
3430 cycles: None,
3431 milestones: None,
3432 reviewer: None,
3433 start_date: None,
3434 due_date: None,
3435 created: "2026-01-01".into(),
3436 type_: None,
3437 updated: "2026-01-01".into(),
3438 filename: format!("2026-01-01-{number}.md"),
3439 body: String::new(),
3440 history: crate::model::History::new(),
3441 has_retired_links_key: false,
3442 time_entries: vec![],
3443 parent: None,
3444 series: None,
3445 health: None,
3446 epics: None,
3447 extra_frontmatter: std::collections::BTreeMap::new(),
3448 archived: false,
3449 entered_at: None,
3450 source: None,
3451 };
3452
3453 let issues = vec![
3454 issue(1, "Platform work", &["p-1"]),
3455 issue(2, "Infra work", &["p-2"]),
3456 issue(3, "Spans both", &["p-1", "p-2"]),
3457 issue(4, "Homeless", &[]),
3458 ];
3459 let texts: Vec<String> = issues.iter().map(Issue::frontmatter).collect();
3460 let corpus = Corpus::with_texts(issues, texts);
3461
3462 let mut config = Config::default();
3463 for (id, name) in [("p-1", "Platform"), ("p-2", "Infra")] {
3464 config.projects.insert(
3465 id.to_string(),
3466 ProjectConfiguration {
3467 name: name.to_string(),
3468 ..ProjectConfiguration::default()
3469 },
3470 );
3471 }
3472 (config, corpus)
3473 }
3474
3475 fn epic_fixture() -> (Config, crate::search::Corpus) {
3479 use crate::config::EpicConfiguration;
3480 use crate::model::Issue;
3481 use crate::search::Corpus;
3482
3483 let make = |number: u32, title: &str, epics: &[&str]| {
3484 let list = if epics.is_empty() {
3485 String::new()
3486 } else {
3487 let ids = epics
3488 .iter()
3489 .map(|id| format!("\"{id}\""))
3490 .collect::<Vec<_>>()
3491 .join(", ");
3492 format!("epics: [{ids}]\n")
3493 };
3494 let text = format!(
3495 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: todo\nlabels: []\n\
3496 priority: medium\n{list}created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody."
3497 );
3498 (Issue::parse(&text).unwrap(), text)
3499 };
3500 let (i1, t1) = make(1, "Alpha work", &["e-aaa111"]);
3501 let (i2, t2) = make(2, "Beta work", &["e-bbb222"]);
3502 let (i3, t3) = make(3, "Spans both", &["e-aaa111", "e-bbb222"]);
3503 let (i4, t4) = make(4, "Homeless", &[]);
3504 let corpus = Corpus::with_texts(vec![i1, i2, i3, i4], vec![t1, t2, t3, t4]);
3505
3506 let mut config = Config::default();
3507 for (id, name) in [("e-aaa111", "Alpha"), ("e-bbb222", "Beta")] {
3508 config.epics.insert(
3509 id.to_string(),
3510 EpicConfiguration {
3511 name: name.to_string(),
3512 ..EpicConfiguration::default()
3513 },
3514 );
3515 }
3516 (config, corpus)
3517 }
3518
3519 fn epic_board_text(board: &BoardState, width: u16, height: u16) -> String {
3522 use ratatui::backend::TestBackend;
3523 let display = BoardDisplay {
3524 wrap_titles: true,
3525 title_lines: 3,
3526 };
3527 let backend = TestBackend::new(width, height);
3528 let mut terminal = ratatui::Terminal::new(backend).unwrap();
3529 terminal
3530 .draw(|f| {
3531 render(
3532 board,
3533 f,
3534 f.area(),
3535 &None,
3536 None,
3537 display,
3538 &crate::ui::color::Theme::default(),
3539 None,
3540 None,
3541 );
3542 })
3543 .unwrap();
3544 let buffer = terminal.backend().buffer();
3545 (0..height)
3546 .map(|y| {
3547 (0..width)
3548 .map(|x| buffer[(x, y)].symbol())
3549 .collect::<String>()
3550 })
3551 .collect::<Vec<_>>()
3552 .join("\n")
3553 }
3554
3555 #[test]
3556 fn t030_the_board_partitions_by_epic_with_a_labelled_no_epic_bucket() {
3557 let (config, corpus) = epic_fixture();
3558 let (_, resolution) = crate::workflow::active_resolution(&config);
3559 let board = BoardState::build_filtered(
3562 config.default_config(),
3563 &config,
3564 &corpus,
3565 "group:epic sort:epic",
3566 &StructuredFilter::default(),
3567 &resolution,
3568 );
3569
3570 let cards = &board.columns[0].cards;
3571 let titles: Vec<&str> = cards.iter().map(|c| c.title.as_str()).collect();
3572 assert_eq!(
3573 titles,
3574 vec!["Alpha work", "Beta work", "Spans both", "Homeless"],
3575 "declaration order, then the multi-epic bucket, then no-epic last"
3576 );
3577
3578 let sections = BoardState::group_sections(cards, Some("epic"))
3579 .expect("group:epic must produce sections");
3580 let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3581 assert_eq!(
3582 names,
3583 vec!["e-aaa111", "e-bbb222", "(multiple epics)", "(no epic)"],
3584 "one bucket per epic in declaration order, then multi, then the \
3585 labelled no-epic bucket last"
3586 );
3587
3588 let footer = BoardState::group_footer(cards, Some("epic")).expect("a footer");
3591 assert!(footer.contains("e-aaa111 0/1 0%"), "got {footer:?}");
3592 assert!(footer.contains("(no epic) 0/1 0%"), "got {footer:?}");
3593 }
3594
3595 #[test]
3596 fn t030_an_unresolved_epic_reference_is_diagnosed_not_a_bucket() {
3597 use crate::model::Issue;
3598 use crate::search::Corpus;
3599
3600 let text = "---\nnumber: 1\ntitle: Stale\nstatus: todo\nlabels: []\npriority: medium\nepics:\n- e-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.";
3603 let issue = Issue::parse(text).unwrap();
3604 let corpus = Corpus::with_texts(vec![issue], vec![text.to_string()]);
3605 let (config, _) = epic_fixture();
3606 let (_, resolution) = crate::workflow::active_resolution(&config);
3607
3608 let filter = StructuredFilter {
3611 epic: Some("e-gone".to_string()),
3612 ..StructuredFilter::default()
3613 };
3614 let board = BoardState::build_filtered(
3615 config.default_config(),
3616 &config,
3617 &corpus,
3618 "",
3619 &filter,
3620 &resolution,
3621 );
3622 assert_eq!(
3623 board.columns[0].cards.len(),
3624 1,
3625 "a stored reference to a deleted epic must still be findable"
3626 );
3627
3628 let board = BoardState::build_filtered(
3631 config.default_config(),
3632 &config,
3633 &corpus,
3634 "group:epic sort:epic",
3635 &StructuredFilter::default(),
3636 &resolution,
3637 );
3638 assert_eq!(
3639 board.columns[0].cards[0].epics,
3640 [] as [std::string::String; 0]
3641 );
3642 let sections = BoardState::group_sections(&board.columns[0].cards, Some("epic"))
3643 .expect("group:epic must produce sections");
3644 let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3645 assert_eq!(
3646 names,
3647 vec!["(no epic)"],
3648 "an unresolvable id is not a bucket"
3649 );
3650 }
3651
3652 #[test]
3653 fn t030_the_epic_filter_narrows_the_board_to_the_members() {
3654 let (config, corpus) = epic_fixture();
3655 let (_, resolution) = crate::workflow::active_resolution(&config);
3656 let members = |id: &str| {
3657 let filter = StructuredFilter {
3658 epic: Some(id.to_string()),
3659 ..StructuredFilter::default()
3660 };
3661 let board = BoardState::build_filtered(
3662 config.default_config(),
3663 &config,
3664 &corpus,
3665 "",
3666 &filter,
3667 &resolution,
3668 );
3669 let mut titles: Vec<String> = board.columns[0]
3670 .cards
3671 .iter()
3672 .map(|c| c.title.clone())
3673 .collect();
3674 titles.sort();
3675 titles
3676 };
3677 assert_eq!(
3678 members("e-aaa111"),
3679 vec!["Alpha work", "Spans both"],
3680 "a member matches and an issue in two epics matches both"
3681 );
3682 assert_eq!(members("e-bbb222"), vec!["Beta work", "Spans both"]);
3683 for unknown in ["e-ccc333", "e-does-not-exist"] {
3686 let filter = StructuredFilter {
3687 epic: Some(unknown.to_string()),
3688 ..StructuredFilter::default()
3689 };
3690 let board = BoardState::build_filtered(
3691 config.default_config(),
3692 &config,
3693 &corpus,
3694 "",
3695 &filter,
3696 &resolution,
3697 );
3698 assert!(board.is_empty(), "epic:{unknown} must match nothing");
3699 }
3700 }
3701
3702 #[test]
3703 fn t030_a_grouped_board_still_opens_the_member_issue_directly() {
3704 let (config, corpus) = epic_fixture();
3708 let (_, resolution) = crate::workflow::active_resolution(&config);
3709 let mut board = BoardState::build_filtered(
3710 config.default_config(),
3711 &config,
3712 &corpus,
3713 "group:epic sort:epic",
3714 &StructuredFilter::default(),
3715 &resolution,
3716 );
3717 board.selected = 2;
3718 let selected = board.get_selected_card().expect("a card under the cursor");
3719 assert_eq!(
3720 selected.number, 3,
3721 "the cursor addresses the issue itself, not an epic"
3722 );
3723 let text = epic_board_text(&board, 140, 40);
3724 assert!(text.contains("-- epic: e-aaa111"), "got {text:?}");
3725 let footer = BoardState::group_footer(&board.columns[0].cards, Some("epic"))
3726 .expect("a grouped board has a footer");
3727 assert!(
3728 footer.contains(GROUP_OPEN_AFFORDANCE),
3729 "the grouped board advertises the direct issue route: {footer:?}"
3730 );
3731 }
3732
3733 #[test]
3734 fn t028_the_board_partitions_by_project_with_a_labelled_no_project_bucket() {
3735 let (config, corpus) = project_fixture();
3736 let (_, resolution) = crate::workflow::active_resolution(&config);
3737 let board = BoardState::build_filtered(
3740 config.default_config(),
3741 &config,
3742 &corpus,
3743 "group:project sort:project",
3744 &StructuredFilter::default(),
3745 &resolution,
3746 );
3747
3748 let cards = &board.columns[0].cards;
3749 let titles: Vec<&str> = cards.iter().map(|c| c.title.as_str()).collect();
3750 assert_eq!(
3751 titles,
3752 vec!["Platform work", "Infra work", "Spans both", "Homeless"],
3753 "declaration order, then the multi-project bucket, then no-project last"
3754 );
3755
3756 let sections = BoardState::group_sections(cards, Some("project"))
3757 .expect("group:project must produce sections");
3758 let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3759 assert_eq!(
3760 names,
3761 vec!["p-1", "p-2", "(multiple projects)", "(no project)"],
3762 "one bucket per project in declaration order, then multi, then the \
3763 labelled no-project bucket last"
3764 );
3765
3766 let footer = BoardState::group_footer(cards, Some("project")).expect("a footer");
3769 assert!(footer.contains("p-1 0/1 0%"), "got {footer:?}");
3770 assert!(footer.contains("(no project) 0/1 0%"), "got {footer:?}");
3771 }
3772
3773 #[test]
3774 fn t028_an_unresolved_association_is_diagnosed_not_a_bucket() {
3775 use crate::model::Issue;
3776 use crate::search::Corpus;
3777
3778 let text = "---\nnumber: 1\ntitle: Stale\nstatus: todo\nlabels: []\npriority: medium\nprojects:\n- p-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.";
3781 let issue = Issue::parse(text).unwrap();
3782 let corpus = Corpus::with_texts(vec![issue], vec![text.to_string()]);
3783 let (config, _) = project_fixture();
3784 let (_, resolution) = crate::workflow::active_resolution(&config);
3785
3786 let filter = StructuredFilter {
3790 project: Some("p-gone".to_string()),
3791 ..StructuredFilter::default()
3792 };
3793 let board = BoardState::build_filtered(
3794 config.default_config(),
3795 &config,
3796 &corpus,
3797 "",
3798 &filter,
3799 &resolution,
3800 );
3801 assert_eq!(
3802 board.columns[0].cards.len(),
3803 1,
3804 "a stored reference to a deleted project must still be findable"
3805 );
3806
3807 let board = BoardState::build_filtered(
3811 config.default_config(),
3812 &config,
3813 &corpus,
3814 "group:project sort:project",
3815 &StructuredFilter::default(),
3816 &resolution,
3817 );
3818 assert_eq!(
3819 board.columns[0].cards[0].projects,
3820 [] as [std::string::String; 0]
3821 );
3822 let sections = BoardState::group_sections(&board.columns[0].cards, Some("project"))
3823 .expect("group:project must produce sections");
3824 let names: Vec<&str> = sections.iter().map(|s| s.name.as_str()).collect();
3825 assert_eq!(
3826 names,
3827 vec!["(no project)"],
3828 "an unresolvable id is diagnosed, not a bucket"
3829 );
3830 }
3831
3832 #[test]
3833 fn t028_the_project_filter_narrows_the_board_to_the_members() {
3834 let (config, corpus) = project_fixture();
3835 let (_, resolution) = crate::workflow::active_resolution(&config);
3836 let filter = StructuredFilter {
3837 project: Some("p-1".to_string()),
3838 ..StructuredFilter::default()
3839 };
3840 let board = BoardState::build_filtered(
3841 config.default_config(),
3842 &config,
3843 &corpus,
3844 "",
3845 &filter,
3846 &resolution,
3847 );
3848 let titles: Vec<&str> = board.columns[0]
3849 .cards
3850 .iter()
3851 .map(|c| c.title.as_str())
3852 .collect();
3853 assert_eq!(
3854 titles,
3855 vec!["Platform work", "Spans both"],
3856 "a member matches and an issue in two projects matches both — so the \
3857 multi-project issue is visible under either project"
3858 );
3859
3860 let other = StructuredFilter {
3862 project: Some("p-2".to_string()),
3863 ..StructuredFilter::default()
3864 };
3865 let board = BoardState::build_filtered(
3866 config.default_config(),
3867 &config,
3868 &corpus,
3869 "",
3870 &other,
3871 &resolution,
3872 );
3873 let titles: Vec<&str> = board.columns[0]
3874 .cards
3875 .iter()
3876 .map(|c| c.title.as_str())
3877 .collect();
3878 assert_eq!(titles, vec!["Infra work", "Spans both"]);
3879
3880 for unknown in ["p-3", "p-does-not-exist"] {
3883 let filter = StructuredFilter {
3884 project: Some(unknown.to_string()),
3885 ..StructuredFilter::default()
3886 };
3887 let board = BoardState::build_filtered(
3888 config.default_config(),
3889 &config,
3890 &corpus,
3891 "",
3892 &filter,
3893 &resolution,
3894 );
3895 assert!(
3896 board.is_empty(),
3897 "project:{unknown} must match nothing, got cards"
3898 );
3899 }
3900 }
3901
3902 #[test]
3903 fn group_footer_reports_done_progress() {
3904 let cards = vec![
3905 Card {
3906 health: None,
3907 number: 1,
3908 title: "A".into(),
3909 priority: crate::model::Priority::Medium,
3910 labels: vec![],
3911 assignee: None,
3912 filename: "a.md".into(),
3913 commit: None,
3914 created: "2026-01-01".into(),
3915 due_date: None,
3916 status: "todo".into(),
3917 category: None,
3918 category_color: None,
3919 done: false,
3920 cycle: Some("Cycle 1".into()),
3921 milestone: None,
3922 blocked: None,
3923 children: None,
3924 parent: None,
3925 depth: 0,
3926 archived: false,
3927 related: 0,
3928 projects: vec![],
3929 epics: Vec::new(),
3930 },
3931 Card {
3932 health: None,
3933 number: 2,
3934 title: "B".into(),
3935 priority: crate::model::Priority::Medium,
3936 labels: vec![],
3937 assignee: None,
3938 filename: "b.md".into(),
3939 commit: None,
3940 created: "2026-01-01".into(),
3941 due_date: None,
3942 status: "done".into(),
3943 category: None,
3944 category_color: None,
3945 done: true,
3946 cycle: Some("Cycle 1".into()),
3947 milestone: None,
3948 blocked: None,
3949 children: None,
3950 parent: None,
3951 depth: 0,
3952 archived: false,
3953 related: 0,
3954 projects: vec![],
3955 epics: Vec::new(),
3956 },
3957 ];
3958 let text = BoardState::group_footer(&cards, Some("cycle")).unwrap();
3959 assert!(text.contains("Cycle 1 1/2 50%"));
3960 }
3961
3962 #[test]
3963 fn build_filtered_bare_number_matches_exact_issue_only() {
3964 use crate::model::Issue;
3965 use crate::search::Corpus;
3966
3967 let to_issue = |number: u32, title: &str, status: &str| Issue {
3968 projects: None,
3969 id: None,
3970 number,
3971 title: title.to_string(),
3972 status: crate::model::Status::new(status),
3973 labels: vec![],
3974 priority: crate::model::Priority::Medium,
3975 assignee: None,
3976 estimate: None,
3977 commit: None,
3978 gitlab_id: None,
3979 linear_id: None,
3980 mission: None,
3981 task: None,
3982 relationships: vec![],
3983 related_issues: vec![],
3984 files: vec![],
3985 cycles: None,
3986 milestones: None,
3987 reviewer: None,
3988 start_date: None,
3989 due_date: None,
3990 created: "2026-01-01".into(),
3991 type_: None,
3992 updated: "2026-01-01".into(),
3993 filename: format!("2026-01-01-{number}.md"),
3994 body: String::new(),
3995 history: crate::model::History::new(),
3996 has_retired_links_key: false,
3997 time_entries: vec![],
3998 parent: None,
3999 series: None,
4000 health: None,
4001 epics: None,
4002 extra_frontmatter: std::collections::BTreeMap::new(),
4003 archived: false,
4004 entered_at: None,
4005 source: None,
4006 };
4007 let corpus = Corpus::with_texts(
4008 vec![
4009 to_issue(55, "Concrete target", "todo"),
4010 to_issue(155, "Digit-rich title", "todo"),
4011 to_issue(3, "Login bug", "todo"),
4012 ],
4013 vec![
4014 "Concrete target".to_string(),
4015 "Digit-rich title".to_string(),
4016 "Login bug".to_string(),
4017 ],
4018 );
4019 let config = Config::default();
4020 let (_, resolution) = crate::workflow::active_resolution(&config);
4021 let board_config = config.default_config();
4022
4023 let exact = BoardState::build_filtered(
4024 board_config,
4025 &config,
4026 &corpus,
4027 "55",
4028 &StructuredFilter::default(),
4029 &resolution,
4030 );
4031 assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
4032 assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
4033 assert_eq!(
4034 exact.column_cards(0)[0].number,
4035 55,
4036 "issue 155's digit sequence must not qualify"
4037 );
4038 assert_eq!(exact.filter.as_deref(), Some("55"));
4039
4040 let miss = BoardState::build_filtered(
4042 board_config,
4043 &config,
4044 &corpus,
4045 "999",
4046 &StructuredFilter::default(),
4047 &resolution,
4048 );
4049 assert!(miss.is_empty(), "no issue 999 => zero matches");
4050
4051 let fuzzy = BoardState::build_filtered(
4053 board_config,
4054 &config,
4055 &corpus,
4056 "login",
4057 &StructuredFilter::default(),
4058 &resolution,
4059 );
4060 assert_eq!(fuzzy.column_cards(0).len(), 1);
4061 assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
4062 }
4063
4064 #[test]
4065 fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
4066 use crate::model::Issue;
4067 use crate::search::Corpus;
4068
4069 let to_issue = |number: u32, title: &str| Issue {
4070 projects: None,
4071 id: None,
4072 number,
4073 title: title.to_string(),
4074 status: crate::model::Status::new("todo"),
4075 labels: vec![],
4076 priority: crate::model::Priority::Medium,
4077 assignee: None,
4078 estimate: None,
4079 commit: None,
4080 gitlab_id: None,
4081 linear_id: None,
4082 mission: None,
4083 task: None,
4084 relationships: vec![],
4085 related_issues: vec![],
4086 files: vec![],
4087 cycles: None,
4088 milestones: None,
4089 reviewer: None,
4090 start_date: None,
4091 due_date: None,
4092 created: "2026-01-01".into(),
4093 type_: None,
4094 updated: "2026-01-01".into(),
4095 filename: format!("2026-01-01-{number}.md"),
4096 body: String::new(),
4097 history: crate::model::History::new(),
4098 has_retired_links_key: false,
4099 time_entries: vec![],
4100 parent: None,
4101 series: None,
4102 health: None,
4103 epics: None,
4104 extra_frontmatter: std::collections::BTreeMap::new(),
4105 archived: false,
4106 entered_at: None,
4107 source: None,
4108 };
4109 let corpus = Corpus::with_texts(
4110 vec![
4111 to_issue(55, "Concrete target"),
4112 to_issue(155, "Digit-rich title"),
4113 ],
4114 vec![
4115 "Concrete target".to_string(),
4116 "Digit-rich title".to_string(),
4117 ],
4118 );
4119 let config = Config::default();
4120 let (_, resolution) = crate::workflow::active_resolution(&config);
4121 let board_config = config.default_config();
4122
4123 let mixed = BoardState::build_filtered(
4126 board_config,
4127 &config,
4128 &corpus,
4129 "55bug",
4130 &StructuredFilter::default(),
4131 &resolution,
4132 );
4133 assert!(mixed.is_empty(), "mixed query is not an exact-number query");
4134 }
4135
4136 #[test]
4137 fn build_filtered_number_composes_with_structured_filter_and() {
4138 use crate::model::Issue;
4139 use crate::search::Corpus;
4140
4141 let cases = [
4142 (55u32, "Issue 55 todo", "todo"),
4143 (156u32, "Issue 156 todo", "todo"),
4144 (57u32, "Issue 57 done", "done"),
4145 ];
4146 let corpus = Corpus::with_texts(
4147 cases
4148 .iter()
4149 .map(|&(number, title, status)| Issue {
4150 projects: None,
4151 id: None,
4152 number,
4153 title: title.to_string(),
4154 status: crate::model::Status::new(status),
4155 labels: vec![],
4156 priority: crate::model::Priority::Medium,
4157 assignee: None,
4158 estimate: None,
4159 commit: None,
4160 gitlab_id: None,
4161 linear_id: None,
4162 mission: None,
4163 task: None,
4164 relationships: vec![],
4165 related_issues: vec![],
4166 files: vec![],
4167 cycles: None,
4168 milestones: None,
4169 reviewer: None,
4170 start_date: None,
4171 due_date: None,
4172 created: "2026-01-01".into(),
4173 type_: None,
4174 updated: "2026-01-01".into(),
4175 filename: format!("2026-01-01-{}.md", number),
4176 body: String::new(),
4177 history: crate::model::History::new(),
4178 has_retired_links_key: false,
4179 time_entries: vec![],
4180 parent: None,
4181 series: None,
4182 health: None,
4183 epics: None,
4184 extra_frontmatter: std::collections::BTreeMap::new(),
4185 archived: false,
4186 entered_at: None,
4187 source: None,
4188 })
4189 .collect(),
4190 cases
4191 .iter()
4192 .map(|(_, title, _)| title.to_string())
4193 .collect(),
4194 );
4195 let config = Config::default();
4196 let (_, resolution) = crate::workflow::active_resolution(&config);
4197 let board_config = config.default_config();
4198 let status_todo = StructuredFilter {
4199 status: Some("todo".to_string()),
4200 ..Default::default()
4201 };
4202 let status_done = StructuredFilter {
4203 status: Some("done".to_string()),
4204 ..Default::default()
4205 };
4206
4207 let matches = BoardState::build_filtered(
4210 board_config,
4211 &config,
4212 &corpus,
4213 "55",
4214 &status_todo,
4215 &resolution,
4216 );
4217 assert_eq!(matches.column_cards(0).len(), 1);
4218 assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
4219
4220 let done_criteria = BoardState::build_filtered(
4223 board_config,
4224 &config,
4225 &corpus,
4226 "55",
4227 &status_done,
4228 &resolution,
4229 );
4230 assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
4231 }
4232
4233 #[test]
4234 fn is_empty_true_only_with_no_cards_anywhere() {
4235 let board = BoardState {
4236 columns: vec![BoardColumn {
4237 name: "a".into(),
4238 cards: vec![],
4239 }],
4240 mismatch: BoardColumn {
4241 name: "Mismatched".into(),
4242 cards: vec![],
4243 },
4244 active_col: 0,
4245 selected: 0,
4246 filter: None,
4247 hover: None,
4248 hover_col: None,
4249 hidden: vec![],
4250 show_mismatch: true,
4251 };
4252 assert!(board.is_empty());
4253
4254 let populated = BoardState {
4255 columns: vec![BoardColumn {
4256 name: "a".into(),
4257 cards: vec![Card {
4258 health: None,
4259 number: 1,
4260 title: "X".into(),
4261 priority: crate::model::Priority::Medium,
4262 labels: vec![],
4263 assignee: None,
4264 filename: "x.md".into(),
4265 commit: None,
4266 created: "2026-01-01".into(),
4267 due_date: None,
4268 status: "todo".into(),
4269 category: None,
4270 category_color: None,
4271 done: false,
4272 cycle: None,
4273 milestone: None,
4274 blocked: None,
4275 children: None,
4276 parent: None,
4277 depth: 0,
4278 archived: false,
4279 related: 0,
4280 projects: vec![],
4281 epics: Vec::new(),
4282 }],
4283 }],
4284 mismatch: BoardColumn {
4285 name: "Mismatched".into(),
4286 cards: vec![],
4287 },
4288 active_col: 0,
4289 selected: 0,
4290 filter: None,
4291 hover: None,
4292 hover_col: None,
4293 hidden: vec![],
4294 show_mismatch: true,
4295 };
4296 assert!(!populated.is_empty());
4297 }
4298
4299 #[test]
4300 fn build_splits_hidden_columns_out_of_visible_surface() {
4301 use crate::config::{BoardConfiguration, Config};
4302
4303 let mut config = Config::default();
4304 config.kanban.configurations.insert(
4305 "default".to_string(),
4306 BoardConfiguration {
4307 columns: vec!["todo".into(), "done".into()],
4308 hidden_columns: vec!["done".into()],
4309 ..BoardConfiguration::default()
4310 },
4311 );
4312 let store = crate::storage::Store::open(&config).unwrap();
4313 let board = BoardState::build(&store, config.default_config());
4314 assert_eq!(board.columns.len(), 1);
4315 assert_eq!(board.columns[0].name, "todo");
4316 assert_eq!(board.hidden.len(), 1);
4317 assert_eq!(board.hidden[0].name, "done");
4318 assert_eq!(board.total_cols(), 1);
4319 }
4320
4321 #[test]
4322 fn move_left_skips_hidden_column() {
4323 let mut board = BoardState {
4324 columns: vec![BoardColumn {
4325 name: "visible-a".into(),
4326 cards: vec![],
4327 }],
4328 hidden: vec![BoardColumn {
4329 name: "hidden-b".into(),
4330 cards: vec![],
4331 }],
4332 mismatch: BoardColumn {
4333 name: "Mismatched".into(),
4334 cards: vec![],
4335 },
4336 active_col: 0,
4337 selected: 0,
4338 filter: None,
4339 hover: None,
4340 hover_col: None,
4341 show_mismatch: true,
4342 };
4343 board.move_left();
4344 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
4345 }
4346
4347 #[test]
4348 fn move_right_skips_hidden_column() {
4349 let mut board = BoardState {
4350 columns: vec![BoardColumn {
4351 name: "visible-a".into(),
4352 cards: vec![],
4353 }],
4354 hidden: vec![BoardColumn {
4355 name: "hidden-b".into(),
4356 cards: vec![],
4357 }],
4358 mismatch: BoardColumn {
4359 name: "Mismatched".into(),
4360 cards: vec![],
4361 },
4362 active_col: 0,
4363 selected: 0,
4364 filter: None,
4365 hover: None,
4366 hover_col: None,
4367 show_mismatch: true,
4368 };
4369 board.move_right();
4370 assert_eq!(board.active_col, 0, "single visible column wraps to itself");
4371 }
4372
4373 #[test]
4374 fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
4375 let mut board = BoardState {
4376 columns: vec![],
4377 hidden: vec![BoardColumn {
4378 name: "only-hidden".into(),
4379 cards: vec![],
4380 }],
4381 mismatch: BoardColumn {
4382 name: "Mismatched".into(),
4383 cards: vec![],
4384 },
4385 active_col: 0,
4386 selected: 0,
4387 filter: None,
4388 hover: None,
4389 hover_col: None,
4390 show_mismatch: false,
4391 };
4392 assert_eq!(board.total_cols(), 0);
4393 board.move_left();
4394 board.move_right();
4395 assert_eq!(board.active_col, 0, "zero-column board must not panic");
4396 let board2 = BoardState {
4397 columns: vec![],
4398 hidden: vec![],
4399 mismatch: BoardColumn {
4400 name: "Mismatched".into(),
4401 cards: vec![],
4402 },
4403 active_col: 0,
4404 selected: 0,
4405 filter: None,
4406 hover: None,
4407 hover_col: None,
4408 show_mismatch: false,
4409 };
4410 assert_eq!(board2.total_cols(), 0);
4411 }
4412
4413 fn write_issue_md(
4414 dir: &std::path::Path,
4415 name: &str,
4416 number: u32,
4417 title: &str,
4418 status: &str,
4419 created: &str,
4420 ) {
4421 let contents = format!(
4422 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
4423 );
4424 std::fs::write(dir.join(name), contents).unwrap();
4425 }
4426
4427 #[test]
4428 fn order_by_sort_desc_asc_none() {
4429 let mk = |created: &str| Card {
4430 health: None,
4431 number: 1,
4432 title: created.to_string(),
4433 priority: crate::model::Priority::Medium,
4434 labels: vec![],
4435 assignee: None,
4436 filename: "x.md".into(),
4437 commit: None,
4438 created: created.to_string(),
4439 due_date: None,
4440 status: "todo".into(),
4441 category: None,
4442 category_color: None,
4443 done: false,
4444 cycle: None,
4445 milestone: None,
4446 blocked: None,
4447 children: None,
4448 parent: None,
4449 depth: 0,
4450 archived: false,
4451 related: 0,
4452 projects: vec![],
4453 epics: Vec::new(),
4454 };
4455 let dates =
4456 |cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
4457
4458 let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
4459 crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
4460 assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
4461
4462 let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
4463 crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
4464 assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
4465
4466 let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
4467 crate::config::OrderBy::None.sort(&mut cards);
4468 assert_eq!(
4469 dates(&cards),
4470 ["2026-03-01", "2026-01-01"],
4471 "none is a no-op"
4472 );
4473 }
4474
4475 #[test]
4476 fn build_orders_most_recent_first_by_default() {
4477 use crate::config::Config;
4478 use std::path::PathBuf;
4479 let tmp = tempfile::TempDir::new().unwrap();
4480 let dir = tmp.path().join("issues");
4481 std::fs::create_dir_all(&dir).unwrap();
4482 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4483 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4484 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4485 let config = Config {
4486 issues_dir: PathBuf::from(&dir),
4487 ..Config::default()
4488 };
4489 let store = crate::storage::Store::open(&config).unwrap();
4490 let board = BoardState::build(&store, config.default_config());
4491 let titles: Vec<&str> = board
4492 .column_cards(0)
4493 .iter()
4494 .map(|c| c.title.as_str())
4495 .collect();
4496 assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
4497 }
4498
4499 #[test]
4500 fn build_applies_created_at_asc_and_none() {
4501 use crate::config::OrderBy;
4502 use std::path::PathBuf;
4503 let tmp = tempfile::TempDir::new().unwrap();
4504 let dir = tmp.path().join("issues");
4505 std::fs::create_dir_all(&dir).unwrap();
4506 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4507 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4508 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4509 let config = crate::config::Config {
4510 issues_dir: PathBuf::from(&dir),
4511 ..crate::config::Config::default()
4512 };
4513 let store = crate::storage::Store::open(&config).unwrap();
4514
4515 let mut cfg_asc = config.clone();
4516 cfg_asc
4517 .kanban
4518 .configurations
4519 .get_mut("default")
4520 .unwrap()
4521 .order_by = OrderBy::CreatedAtAsc;
4522 let board_asc = BoardState::build(&store, cfg_asc.default_config());
4523 let titles: Vec<&str> = board_asc
4524 .column_cards(0)
4525 .iter()
4526 .map(|c| c.title.as_str())
4527 .collect();
4528 assert_eq!(titles, ["old", "mid", "new"]);
4529
4530 let mut cfg_none = config;
4531 cfg_none
4532 .kanban
4533 .configurations
4534 .get_mut("default")
4535 .unwrap()
4536 .order_by = OrderBy::None;
4537 let board_none = BoardState::build(&store, cfg_none.default_config());
4538 let titles: Vec<&str> = board_none
4539 .column_cards(0)
4540 .iter()
4541 .map(|c| c.title.as_str())
4542 .collect();
4543 assert_eq!(
4544 titles,
4545 ["old", "mid", "new"],
4546 "none keeps the store's filename-ascending order"
4547 );
4548 }
4549
4550 #[test]
4551 fn build_applies_sparse_manual_card_order_when_present() {
4552 use crate::config::Config;
4553 use std::path::PathBuf;
4554 let tmp = tempfile::TempDir::new().unwrap();
4555 let dir = tmp.path().join("issues");
4556 std::fs::create_dir_all(&dir).unwrap();
4557 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4558 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4559 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4560 let config = Config {
4561 issues_dir: PathBuf::from(&dir),
4562 ..Config::default()
4563 };
4564 let store = crate::storage::Store::open(&config).unwrap();
4565 let mut board_cfg = config.default_config().clone();
4566 board_cfg.card_order = vec!["2026-02-01-mid.md".into(), "2026-03-01-new.md".into()];
4567 let board = BoardState::build(&store, &board_cfg);
4568 let titles: Vec<&str> = board
4569 .column_cards(0)
4570 .iter()
4571 .map(|c| c.title.as_str())
4572 .collect();
4573 assert_eq!(titles, ["mid", "new", "old"], "manual order wins");
4574 }
4575
4576 #[test]
4577 fn sparse_card_order_only_returns_changed_cards() {
4578 use crate::config::Config;
4579 use std::path::PathBuf;
4580
4581 let tmp = tempfile::TempDir::new().unwrap();
4582 let dir = tmp.path().join("issues");
4583 std::fs::create_dir_all(&dir).unwrap();
4584 write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
4585 write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
4586 write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
4587 let config = Config {
4588 issues_dir: PathBuf::from(&dir),
4589 ..Config::default()
4590 };
4591 let store = crate::storage::Store::open(&config).unwrap();
4592 let board_cfg = config.default_config().clone();
4593 let mut cards = BoardState::build(&store, &board_cfg)
4594 .column_cards(0)
4595 .to_vec();
4596 cards.swap(0, 1);
4597 let sparse = sparse_card_order(&board_cfg, &cards);
4598 assert_eq!(sparse, ["2026-02-01-mid.md", "2026-03-01-new.md"]);
4599 }
4600
4601 #[test]
4602 fn build_filtered_applies_order_by() {
4603 use crate::model::Issue;
4604 use crate::search::{Corpus, StructuredFilter};
4605
4606 let to_issue = |number: u32, title: &str, created: &str| Issue {
4607 projects: None,
4608 id: None,
4609 number,
4610 title: title.to_string(),
4611 status: crate::model::Status::new("todo"),
4612 labels: vec![],
4613 priority: crate::model::Priority::Medium,
4614 assignee: None,
4615 estimate: None,
4616 commit: None,
4617 gitlab_id: None,
4618 linear_id: None,
4619 mission: None,
4620 task: None,
4621 relationships: vec![],
4622 related_issues: vec![],
4623 files: vec![],
4624 cycles: None,
4625 milestones: None,
4626 reviewer: None,
4627 start_date: None,
4628 due_date: None,
4629 created: created.into(),
4630 type_: None,
4631 updated: "2026-01-01".into(),
4632 filename: format!("2026-01-01-{number}.md"),
4633 body: String::new(),
4634 history: crate::model::History::new(),
4635 has_retired_links_key: false,
4636 time_entries: vec![],
4637 parent: None,
4638 series: None,
4639 health: None,
4640 epics: None,
4641 extra_frontmatter: std::collections::BTreeMap::new(),
4642 archived: false,
4643 entered_at: None,
4644 source: None,
4645 };
4646 let corpus = Corpus::with_texts(
4647 vec![
4648 to_issue(1, "old", "2026-01-01"),
4649 to_issue(2, "new", "2026-03-01"),
4650 to_issue(3, "mid", "2026-02-01"),
4651 ],
4652 vec!["old".into(), "new".into(), "mid".into()],
4653 );
4654 let config = crate::config::Config::default();
4655 let (_, resolution) = crate::workflow::active_resolution(&config);
4656 let board = BoardState::build_filtered(
4657 config.default_config(),
4658 &config,
4659 &corpus,
4660 "",
4661 &StructuredFilter::default(),
4662 &resolution,
4663 );
4664 let titles: Vec<&str> = board
4665 .column_cards(0)
4666 .iter()
4667 .map(|c| c.title.as_str())
4668 .collect();
4669 assert_eq!(
4670 titles,
4671 ["new", "mid", "old"],
4672 "default desc applies to filtered results too"
4673 );
4674 }
4675
4676 fn tree_card(number: u32, parent: Option<u32>) -> Card {
4679 Card {
4680 health: None,
4681 number,
4682 title: format!("Issue {number}"),
4683 priority: Priority::Medium,
4684 labels: vec![],
4685 assignee: None,
4686 filename: format!("{number}.md"),
4687 commit: None,
4688 created: "2026-01-01".into(),
4689 due_date: None,
4690 status: "todo".into(),
4691 category: None,
4692 category_color: None,
4693 done: false,
4694 cycle: None,
4695 milestone: None,
4696 blocked: None,
4697 children: None,
4698 parent,
4699 depth: 0,
4700 archived: false,
4701 related: 0,
4702 projects: vec![],
4703 epics: Vec::new(),
4704 }
4705 }
4706
4707 #[test]
4708 fn nest_children_places_a_child_after_its_parent() {
4709 let mut cards = vec![
4710 tree_card(2, Some(1)),
4711 tree_card(1, None),
4712 tree_card(3, None),
4713 ];
4714 nest_children(&mut cards);
4715 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4716 assert_eq!(
4717 shape,
4718 vec![(1, 0), (2, 1), (3, 0)],
4719 "the child moves up under its parent and is marked depth 1"
4720 );
4721 }
4722
4723 #[test]
4724 fn nest_children_keeps_adopted_siblings_in_their_existing_order() {
4725 let mut cards = vec![
4726 tree_card(1, None),
4727 tree_card(3, Some(1)),
4728 tree_card(2, Some(1)),
4729 ];
4730 nest_children(&mut cards);
4731 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4732 assert_eq!(shape, vec![(1, 0), (3, 1), (2, 1)]);
4733 }
4734
4735 #[test]
4736 fn nest_children_does_not_nest_a_grandchild() {
4737 let mut cards = vec![
4740 tree_card(1, None),
4741 tree_card(2, Some(1)),
4742 tree_card(3, Some(2)),
4743 ];
4744 nest_children(&mut cards);
4745 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4746 assert_eq!(shape, vec![(1, 0), (2, 1), (3, 0)]);
4747 }
4748
4749 #[test]
4750 fn nest_children_leaves_a_child_whose_parent_is_in_another_column() {
4751 let mut cards = vec![tree_card(2, Some(1)), tree_card(5, None)];
4753 nest_children(&mut cards);
4754 let shape: Vec<(u32, u8)> = cards.iter().map(|c| (c.number, c.depth)).collect();
4755 assert_eq!(shape, vec![(2, 0), (5, 0)]);
4756 }
4757
4758 #[test]
4759 fn nest_children_keeps_every_card_exactly_once() {
4760 let mut cards = vec![
4761 tree_card(4, None),
4762 tree_card(3, Some(1)),
4763 tree_card(1, None),
4764 tree_card(2, Some(1)),
4765 ];
4766 nest_children(&mut cards);
4767 let mut numbers: Vec<u32> = cards.iter().map(|c| c.number).collect();
4768 numbers.sort_unstable();
4769 assert_eq!(
4770 numbers,
4771 vec![1, 2, 3, 4],
4772 "no card is duplicated or dropped"
4773 );
4774 }
4775
4776 #[test]
4777 fn render_indents_a_sub_issue_without_changing_its_line_count() {
4778 use ratatui::Terminal;
4779 use ratatui::backend::TestBackend;
4780
4781 let width = 80u16;
4782 let height = 30u16;
4783 let mut child = tree_card(2, Some(1));
4784 child.title = "a sub-issue whose deliberately long title wraps across more \
4785 than a single rendered row inside the column"
4786 .into();
4787 child.depth = 1;
4788 let parent = tree_card(1, None);
4789
4790 let mut flat_child = child.clone();
4791 flat_child.depth = 0;
4792 assert_eq!(
4793 widgets::card_line_count(&child, width as usize, true, 3),
4794 widgets::card_line_count(&flat_child, width as usize, true, 3),
4795 "the indent must not change the card's rendered line count"
4796 );
4797
4798 let board = BoardState {
4799 columns: vec![BoardColumn {
4800 name: "todo".into(),
4801 cards: vec![parent, child],
4802 }],
4803 hidden: vec![],
4804 mismatch: BoardColumn {
4805 name: "Mismatched".into(),
4806 cards: vec![],
4807 },
4808 active_col: 0,
4809 selected: 0,
4810 filter: None,
4811 hover: None,
4812 hover_col: None,
4813 show_mismatch: false,
4814 };
4815 let display = BoardDisplay {
4816 wrap_titles: true,
4817 title_lines: 3,
4818 };
4819 let backend = TestBackend::new(width, height);
4820 let mut terminal = Terminal::new(backend).unwrap();
4821 terminal
4822 .draw(|f| {
4823 render(
4824 &board,
4825 f,
4826 f.area(),
4827 &None,
4828 None,
4829 display,
4830 &crate::ui::color::Theme::default(),
4831 None,
4832 None,
4833 );
4834 })
4835 .unwrap();
4836 let buffer = terminal.backend().buffer();
4837 let row = |y: u16| -> String { (0..width).map(|x| buffer[(x, y)].symbol()).collect() };
4838
4839 let symbol = |x: u16, y: u16| buffer[(x, y)].symbol().to_string();
4840 assert!(
4841 row(1).contains("#1"),
4842 "the parent draws first: {:?}",
4843 row(1)
4844 );
4845 assert_eq!(
4846 symbol(1, 1),
4847 "#",
4848 "a top-level card is flush with the column border: {:?}",
4849 row(1)
4850 );
4851 assert_eq!(
4852 (symbol(1, 2), symbol(2, 2)),
4853 (" ".to_string(), "2".to_string()),
4854 "the sub-issue draws a one-character ASCII whitespace margin and drops \
4855 the `#` (tix #1536): {:?}",
4856 row(2)
4857 );
4858 assert_eq!(
4862 (symbol(2, 1), symbol(2, 2)),
4863 ("1".to_string(), "2".to_string()),
4864 "the parent's and sub-issue's digits align at the same column: {:?} / {:?}",
4865 row(1),
4866 row(2)
4867 );
4868
4869 let area = Rect::new(0, 0, width, height);
4872 assert_eq!(
4873 board.hit_test(area, display, false, 3, 1),
4874 Some(HitTarget::Card { col: 0, row: 0 })
4875 );
4876 assert_eq!(
4877 board.hit_test(area, display, false, 3, 2),
4878 Some(HitTarget::Card { col: 0, row: 1 })
4879 );
4880 }
4881
4882 fn write_issue_with_parent_md(
4885 dir: &std::path::Path,
4886 name: &str,
4887 number: u32,
4888 title: &str,
4889 status: &str,
4890 created: &str,
4891 parent: Option<u32>,
4892 ) {
4893 let parent_line = parent.map(|p| format!("parent: {p}\n")).unwrap_or_default();
4894 let contents = format!(
4895 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n{parent_line}---\n\nBody text."
4896 );
4897 std::fs::write(dir.join(name), contents).unwrap();
4898 }
4899
4900 #[test]
4901 fn build_with_show_children_nests_a_sub_issue_below_its_parent() {
4902 use crate::config::Config;
4903 use std::path::PathBuf;
4904
4905 let tmp = tempfile::TempDir::new().unwrap();
4906 let dir = tmp.path().join("issues");
4907 std::fs::create_dir_all(&dir).unwrap();
4908 write_issue_with_parent_md(
4909 &dir,
4910 "2026-01-01-parent.md",
4911 1,
4912 "parent",
4913 "todo",
4914 "2026-01-01",
4915 None,
4916 );
4917 write_issue_with_parent_md(
4918 &dir,
4919 "2026-01-02-child.md",
4920 2,
4921 "child",
4922 "todo",
4923 "2026-01-02",
4924 Some(1),
4925 );
4926 let config = Config {
4927 issues_dir: PathBuf::from(&dir),
4928 ..Config::default()
4929 };
4930 let store = crate::storage::Store::open(&config).unwrap();
4931 let board_cfg = config.default_config().clone();
4932
4933 let flat = BoardState::build(&store, &board_cfg);
4936 let flat_shape: Vec<(u32, u8)> = flat
4937 .column_cards(0)
4938 .iter()
4939 .map(|c| (c.number, c.depth))
4940 .collect();
4941 assert_eq!(flat_shape, vec![(2, 0), (1, 0)]);
4942
4943 let tree = BoardState::build_with(&store, &board_cfg, false, true);
4944 let tree_shape: Vec<(u32, u8)> = tree
4945 .column_cards(0)
4946 .iter()
4947 .map(|c| (c.number, c.depth))
4948 .collect();
4949 assert_eq!(
4950 tree_shape,
4951 vec![(1, 0), (2, 1)],
4952 "the sub-issue follows its parent, one level deep"
4953 );
4954 assert_eq!(tree.column_cards(0)[1].parent, Some(1));
4955 }
4956
4957 #[test]
4960 fn build_from_issues_matches_build_with() {
4961 use crate::config::Config;
4962
4963 let tmp = tempfile::TempDir::new().unwrap();
4964 let dir = tmp.path().join("issues");
4965 std::fs::create_dir_all(&dir).unwrap();
4966 write_issue_with_parent_md(
4967 &dir,
4968 "2026-01-01-parent.md",
4969 1,
4970 "parent",
4971 "todo",
4972 "2026-01-01",
4973 None,
4974 );
4975 write_issue_with_parent_md(
4976 &dir,
4977 "2026-01-02-child.md",
4978 2,
4979 "child",
4980 "todo",
4981 "2026-01-02",
4982 Some(1),
4983 );
4984 write_issue_md(
4985 &dir,
4986 "2026-01-03-other.md",
4987 3,
4988 "other",
4989 "in progress",
4990 "2026-01-03",
4991 );
4992 let config = Config {
4993 issues_dir: dir.clone(),
4994 ..Config::default()
4995 };
4996 let store = crate::storage::Store::open(&config).unwrap();
4997 let board_cfg = config.default_config().clone();
4998
4999 let shape = |state: &BoardState| -> Vec<(String, Vec<(String, u8)>)> {
5000 state
5001 .columns
5002 .iter()
5003 .chain(state.hidden.iter())
5004 .chain(std::iter::once(&state.mismatch))
5005 .map(|col| {
5006 (
5007 col.name.clone(),
5008 col.cards
5009 .iter()
5010 .map(|c| (c.filename.clone(), c.depth))
5011 .collect(),
5012 )
5013 })
5014 .collect()
5015 };
5016
5017 for show_children in [false, true] {
5018 let from_store = BoardState::build_with(&store, &board_cfg, false, show_children);
5019 let issues = store.list().unwrap();
5020 let resolution = BoardState::resolution_for(&store, &board_cfg);
5021 let from_issues = BoardState::build_from_issues(
5022 &issues,
5023 &board_cfg,
5024 &config,
5025 &resolution,
5026 show_children,
5027 );
5028 assert_eq!(
5029 shape(&from_issues),
5030 shape(&from_store),
5031 "show_children={show_children}"
5032 );
5033 }
5034 }
5035}