use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::config::BoardConfiguration;
use crate::model::Priority;
use crate::search::{Corpus, StructuredFilter};
use crate::storage::Store;
use super::widgets;
#[derive(Debug, Clone)]
pub struct Card {
pub number: u32,
pub title: String,
pub priority: Priority,
pub labels: Vec<String>,
pub assignee: Option<String>,
pub filename: String,
pub commit: Option<String>,
pub created: String,
}
impl crate::config::OrderBy {
pub fn sort(&self, cards: &mut [Card]) {
match self {
crate::config::OrderBy::CreatedAtDesc => {
cards.sort_by(|a, b| b.created.cmp(&a.created))
}
crate::config::OrderBy::CreatedAtAsc => cards.sort_by(|a, b| a.created.cmp(&b.created)),
crate::config::OrderBy::None => {}
}
}
}
#[derive(Debug, Clone)]
pub struct BoardColumn {
pub name: String,
pub cards: Vec<Card>,
}
#[derive(Debug, Clone)]
pub struct BoardState {
pub columns: Vec<BoardColumn>,
pub hidden: Vec<BoardColumn>,
pub mismatch: BoardColumn,
pub show_mismatch: bool,
pub active_col: usize,
pub selected: usize,
pub filter: Option<String>,
}
impl BoardState {
pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
let issues = store.list().unwrap_or_default();
let show_mismatch = board_config.show_mismatch;
let hidden_names: Vec<&str> = board_config
.hidden_columns
.iter()
.map(String::as_str)
.collect();
let mut column_map: Vec<Vec<Card>> =
board_config.columns.iter().map(|_| Vec::new()).collect();
let mut mismatch_cards = Vec::new();
for issue in issues {
let card = Card {
number: issue.number,
title: issue.title.clone(),
priority: issue.priority.clone(),
labels: issue.labels.clone(),
assignee: issue.assignee.clone(),
filename: issue.filename.clone(),
commit: issue.commit.clone(),
created: issue.created.clone(),
};
if let Some(idx) = board_config
.columns
.iter()
.position(|c| c == issue.status.display())
{
column_map[idx].push(card);
} else {
mismatch_cards.push(card);
}
}
for cards in column_map.iter_mut() {
board_config.order_by.sort(cards);
}
board_config.order_by.sort(&mut mismatch_cards);
let mut columns: Vec<BoardColumn> = Vec::new();
let mut hidden: Vec<BoardColumn> = Vec::new();
for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
let col = BoardColumn {
name: name.clone(),
cards,
};
if hidden_names.contains(&name.as_str()) {
hidden.push(col);
} else {
columns.push(col);
}
}
let mismatch = BoardColumn {
name: "Mismatched".to_string(),
cards: mismatch_cards,
};
BoardState {
columns,
hidden,
mismatch,
show_mismatch,
active_col: 0,
selected: 0,
filter: None,
}
}
pub fn build_filtered(
board_config: &BoardConfiguration,
corpus: &Corpus,
query: &str,
filter: &StructuredFilter,
) -> Self {
let show_mismatch = board_config.show_mismatch;
let filtering = !query.is_empty() || filter.has_active_criteria();
let number = crate::search::number_query(query);
let hidden_names: Vec<&str> = board_config
.hidden_columns
.iter()
.map(String::as_str)
.collect();
let mut column_map: Vec<Vec<Card>> =
board_config.columns.iter().map(|_| Vec::new()).collect();
let mut mismatch_cards = Vec::new();
for (issue, text) in corpus.issues.iter().zip(corpus.texts.iter()) {
let keep = if let Some(n) = number {
issue.number == n && filter.evaluate(text)
} else {
crate::search::matches(query, filter, text)
};
if !keep {
continue;
}
let card = Card {
number: issue.number,
title: issue.title.clone(),
priority: issue.priority.clone(),
labels: issue.labels.clone(),
assignee: issue.assignee.clone(),
filename: issue.filename.clone(),
commit: issue.commit.clone(),
created: issue.created.clone(),
};
if let Some(idx) = board_config
.columns
.iter()
.position(|c| c == issue.status.display())
{
column_map[idx].push(card);
} else {
mismatch_cards.push(card);
}
}
for cards in column_map.iter_mut() {
board_config.order_by.sort(cards);
}
board_config.order_by.sort(&mut mismatch_cards);
let mut columns: Vec<BoardColumn> = Vec::new();
let mut hidden: Vec<BoardColumn> = Vec::new();
for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
let col = BoardColumn {
name: name.clone(),
cards,
};
if hidden_names.contains(&name.as_str()) {
if filtering {
columns.push(col);
} else {
hidden.push(col);
}
} else {
columns.push(col);
}
}
BoardState {
columns,
hidden,
mismatch: BoardColumn {
name: "Mismatched".to_string(),
cards: mismatch_cards,
},
show_mismatch,
active_col: 0,
selected: 0,
filter: Some(query.to_string()),
}
}
pub fn is_empty(&self) -> bool {
self.columns.iter().all(|c| c.cards.is_empty())
&& (!self.show_mismatch || self.mismatch.cards.is_empty())
}
pub fn total_cols(&self) -> usize {
if self.show_mismatch {
self.columns.len() + 1
} else {
self.columns.len()
}
}
pub fn real_cols(&self) -> usize {
self.columns.len()
}
pub fn column_name(&self, idx: usize) -> &str {
if idx < self.columns.len() {
&self.columns[idx].name
} else if idx == self.columns.len() && self.show_mismatch {
&self.mismatch.name
} else {
"Unknown"
}
}
pub fn active_column_name(&self) -> &str {
let visible = self.columns.len();
if self.active_col < visible {
&self.columns[self.active_col].name
} else {
let hidden_idx = self.active_col - visible;
if hidden_idx < self.hidden.len() {
&self.hidden[hidden_idx].name
} else {
"Unknown"
}
}
}
pub fn column_cards(&self, idx: usize) -> &[Card] {
if idx < self.columns.len() {
&self.columns[idx].cards
} else if idx == self.columns.len() && self.show_mismatch {
&self.mismatch.cards
} else {
&[]
}
}
pub fn get_selected_card(&self) -> Option<&Card> {
self.column_cards(self.active_col).get(self.selected)
}
pub fn move_left(&mut self) {
let total = self.real_cols();
self.active_col = if total == 0 {
0
} else if self.active_col >= total {
total - 1
} else {
(self.active_col + total - 1) % total
};
self.clamp_selection();
}
pub fn move_right(&mut self) {
let total = self.real_cols();
self.active_col = if total == 0 || self.active_col >= total {
0
} else {
(self.active_col + 1) % total
};
self.clamp_selection();
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
let cards = self.column_cards(self.active_col);
if self.selected + 1 < cards.len() {
self.selected += 1;
}
}
pub fn clamp_selection(&mut self) {
let cards = self.column_cards(self.active_col);
if cards.is_empty() {
self.selected = 0;
} else if self.selected >= cards.len() {
self.selected = cards.len() - 1;
}
}
pub fn select_column(&mut self, col: usize) {
let total = self.real_cols();
if total > 0 {
self.active_col = col % total;
}
self.clamp_selection();
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoardDisplay {
pub wrap_titles: bool,
pub title_lines: usize,
}
impl Default for BoardDisplay {
fn default() -> Self {
BoardDisplay {
wrap_titles: true,
title_lines: 3,
}
}
}
impl From<&crate::config::BoardConfiguration> for BoardDisplay {
fn from(config: &crate::config::BoardConfiguration) -> Self {
BoardDisplay {
wrap_titles: config.wrap_titles,
title_lines: config.title_lines,
}
}
}
pub fn render(
board: &BoardState,
f: &mut Frame,
area: Rect,
status_msg: &Option<String>,
display: BoardDisplay,
) {
if area.height < 2 || area.width < 4 {
return;
}
let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
let board_height = area.height.saturating_sub(status_height);
let board_area = Rect {
height: board_height,
..area
};
let status_area = Rect {
y: area.y + board_height,
height: status_height,
..area
};
let total = board.total_cols();
if total == 0 {
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Ratio(1, total as u32); total])
.split(board_area);
for i in 0..total {
let col = if i < board.columns.len() {
&board.columns[i]
} else {
&board.mismatch
};
let is_active = i == board.active_col;
let is_mismatch_col = i == board.columns.len();
let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
let items: Vec<ListItem> = col
.cards
.iter()
.enumerate()
.map(|(j, card)| {
let lines = widgets::card_lines(
card,
chunks[i].width as usize,
display.wrap_titles,
display.title_lines,
);
let style = if is_active && j == board.selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else if is_mismatch_highlight {
Style::default().fg(Color::Red)
} else {
Style::default()
};
ListItem::new(lines).style(style)
})
.collect();
let title = format!("{} ({})", col.name, col.cards.len());
let border_style = if is_active {
Style::default().fg(Color::Cyan)
} else if is_mismatch_highlight {
Style::default().fg(Color::Red)
} else {
Style::default()
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
let list = List::new(items).block(block);
if is_active {
let mut state = ListState::default();
if !col.cards.is_empty() {
state.select(Some(board.selected));
}
f.render_stateful_widget(list, chunks[i], &mut state);
} else {
f.render_widget(list, chunks[i]);
}
}
if let Some(msg) = status_msg {
let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
.style(Style::default().fg(Color::Yellow));
f.render_widget(para, status_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn total_cols_includes_mismatch() {
let board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert_eq!(board.total_cols(), 3);
}
#[test]
fn column_name_mismatch_index() {
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert_eq!(board.column_name(0), "todo");
assert_eq!(board.column_name(1), "Mismatched");
}
#[test]
fn move_left_wraps_around() {
let mut board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: true,
};
board.move_left();
assert_eq!(board.active_col, 1);
}
#[test]
fn move_right_wraps_around() {
let mut board = BoardState {
columns: vec![
BoardColumn {
name: "a".into(),
cards: vec![],
},
BoardColumn {
name: "b".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 2,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.move_right();
assert_eq!(board.active_col, 0);
}
#[test]
fn clamp_selection_empty_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 5,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.clamp_selection();
assert_eq!(board.selected, 0);
}
#[test]
fn clamp_selection_single_card() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 5,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.clamp_selection();
assert_eq!(board.selected, 0);
}
#[test]
fn move_up_clamps_at_top() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.move_up();
assert_eq!(board.selected, 0);
}
#[test]
fn move_down_clamps_at_bottom() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
board.move_down();
assert_eq!(board.selected, 0);
}
#[test]
fn render_smoke_does_not_panic_on_tiny_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
terminal
.draw(|f| render(&board, f, f.area(), &None, BoardDisplay::default()))
.unwrap();
}
#[test]
fn render_smoke_full_board() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![
BoardColumn {
name: "todo".into(),
cards: vec![],
},
BoardColumn {
name: "done".into(),
cards: vec![],
},
],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
terminal
.draw(|f| {
render(
&board,
f,
f.area(),
&Some("test msg".into()),
BoardDisplay::default(),
)
})
.unwrap();
}
#[test]
fn render_selected_multiline_card_highlights_all_wrapped_lines() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
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";
let card = crate::ui::board::Card {
number: 7,
title: title.into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let board = BoardState {
columns: vec![BoardColumn {
name: "todo".into(),
cards: vec![card],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: false,
};
let display = BoardDisplay {
wrap_titles: true,
title_lines: 3,
};
terminal
.draw(|f| render(&board, f, f.area(), &None, display))
.unwrap();
let buffer = terminal.backend().buffer();
let mut highlighted = 0;
for y in 1..buffer.area().height - 1 {
let cell = &buffer[(2, y)];
if cell.bg != Color::DarkGray {
break;
}
assert!(
cell.modifier.contains(Modifier::BOLD),
"highlighted continuation line must stay bold: {cell:?}"
);
highlighted += 1;
}
assert!(
highlighted >= 2,
"wrapped card highlights every line, got {highlighted}"
);
assert!(
highlighted <= display.title_lines,
"highlight never exceeds the line limit: {highlighted}"
);
}
#[test]
fn build_filtered_keeps_only_fuzzy_matches() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str| Issue {
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
};
let corpus = Corpus {
issues: vec![to_issue(1, "Fix login bug"), to_issue(2, "Polish exports")],
texts: vec!["Fix login bug".to_string(), "Polish exports".to_string()],
};
let config = Config::default();
let board_config = config.default_config();
let board =
BoardState::build_filtered(board_config, &corpus, "log", &StructuredFilter::default());
assert_eq!(board.columns.len(), board_config.columns.len());
assert_eq!(board.column_cards(0).len(), 1);
assert_eq!(board.column_cards(0)[0].title, "Fix login bug");
assert_eq!(board.filter.as_deref(), Some("log"));
let none =
BoardState::build_filtered(board_config, &corpus, "zzw", &StructuredFilter::default());
assert!(none.is_empty());
let empty =
BoardState::build_filtered(board_config, &corpus, "", &StructuredFilter::default());
assert_eq!(empty.column_cards(0).len(), 2, "empty query keeps all");
assert!(empty.filter.is_some());
}
#[test]
fn build_filtered_bare_number_matches_exact_issue_only() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str, status: &str| Issue {
number,
title: title.to_string(),
status: crate::model::Status::new(status),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
};
let corpus = Corpus {
issues: vec![
to_issue(55, "Concrete target", "todo"),
to_issue(155, "Digit-rich title", "todo"),
to_issue(3, "Login bug", "todo"),
],
texts: vec![
"Concrete target".to_string(),
"Digit-rich title".to_string(),
"Login bug".to_string(),
],
};
let config = Config::default();
let board_config = config.default_config();
let exact =
BoardState::build_filtered(board_config, &corpus, "55", &StructuredFilter::default());
assert_eq!(exact.column_cards(0).len(), 1, "only issue 55 matches");
assert_eq!(exact.column_cards(0)[0].title, "Concrete target");
assert_eq!(
exact.column_cards(0)[0].number,
55,
"issue 155's digit sequence must not qualify"
);
assert_eq!(exact.filter.as_deref(), Some("55"));
let miss =
BoardState::build_filtered(board_config, &corpus, "999", &StructuredFilter::default());
assert!(miss.is_empty(), "no issue 999 => zero matches");
let fuzzy = BoardState::build_filtered(
board_config,
&corpus,
"login",
&StructuredFilter::default(),
);
assert_eq!(fuzzy.column_cards(0).len(), 1);
assert_eq!(fuzzy.column_cards(0)[0].title, "Login bug");
}
#[test]
fn build_filtered_mixed_query_keeps_fuzzy_not_number() {
use crate::model::Issue;
use crate::search::Corpus;
let to_issue = |number: u32, title: &str| Issue {
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
};
let corpus = Corpus {
issues: vec![
to_issue(55, "Concrete target"),
to_issue(155, "Digit-rich title"),
],
texts: vec![
"Concrete target".to_string(),
"Digit-rich title".to_string(),
],
};
let config = Config::default();
let board_config = config.default_config();
let mixed = BoardState::build_filtered(
board_config,
&corpus,
"55bug",
&StructuredFilter::default(),
);
assert!(mixed.is_empty(), "mixed query is not an exact-number query");
}
#[test]
fn build_filtered_number_composes_with_structured_filter_and() {
use crate::model::Issue;
use crate::search::Corpus;
let cases = [
(55u32, "Issue 55 todo", "todo"),
(156u32, "Issue 156 todo", "todo"),
(57u32, "Issue 57 done", "done"),
];
let corpus = Corpus {
issues: cases
.iter()
.map(|&(number, title, status)| Issue {
number,
title: title.to_string(),
status: crate::model::Status::new(status),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{}.md", number),
body: String::new(),
})
.collect(),
texts: cases
.iter()
.map(|(_, title, _)| title.to_string())
.collect(),
};
let config = Config::default();
let board_config = config.default_config();
let status_todo = StructuredFilter {
status: Some("todo".to_string()),
..Default::default()
};
let status_done = StructuredFilter {
status: Some("done".to_string()),
..Default::default()
};
let matches = BoardState::build_filtered(board_config, &corpus, "55", &status_todo);
assert_eq!(matches.column_cards(0).len(), 1);
assert_eq!(matches.column_cards(0)[0].title, "Issue 55 todo");
let done_criteria = BoardState::build_filtered(board_config, &corpus, "55", &status_done);
assert!(done_criteria.is_empty(), "criteria failure hides issue 55");
}
#[test]
fn is_empty_true_only_with_no_cards_anywhere() {
let board = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert!(board.is_empty());
let populated = BoardState {
columns: vec![BoardColumn {
name: "a".into(),
cards: vec![Card {
number: 1,
title: "X".into(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: "2026-01-01".into(),
}],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
hidden: vec![],
show_mismatch: true,
};
assert!(!populated.is_empty());
}
#[test]
fn build_splits_hidden_columns_out_of_visible_surface() {
use crate::config::{BoardConfiguration, Config};
let mut config = Config::default();
config.kanban.configurations.insert(
"default".to_string(),
BoardConfiguration {
columns: vec!["todo".into(), "done".into()],
hidden_columns: vec!["done".into()],
..BoardConfiguration::default()
},
);
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
assert_eq!(board.columns.len(), 1);
assert_eq!(board.columns[0].name, "todo");
assert_eq!(board.hidden.len(), 1);
assert_eq!(board.hidden[0].name, "done");
assert_eq!(board.total_cols(), 1);
}
#[test]
fn move_left_skips_hidden_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "visible-a".into(),
cards: vec![],
}],
hidden: vec![BoardColumn {
name: "hidden-b".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: true,
};
board.move_left();
assert_eq!(board.active_col, 0, "single visible column wraps to itself");
}
#[test]
fn move_right_skips_hidden_column() {
let mut board = BoardState {
columns: vec![BoardColumn {
name: "visible-a".into(),
cards: vec![],
}],
hidden: vec![BoardColumn {
name: "hidden-b".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: true,
};
board.move_right();
assert_eq!(board.active_col, 0, "single visible column wraps to itself");
}
#[test]
fn all_columns_hidden_board_is_displayable_and_navigation_safe() {
let mut board = BoardState {
columns: vec![],
hidden: vec![BoardColumn {
name: "only-hidden".into(),
cards: vec![],
}],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: false,
};
assert_eq!(board.total_cols(), 0);
board.move_left();
board.move_right();
assert_eq!(board.active_col, 0, "zero-column board must not panic");
let board2 = BoardState {
columns: vec![],
hidden: vec![],
mismatch: BoardColumn {
name: "Mismatched".into(),
cards: vec![],
},
active_col: 0,
selected: 0,
filter: None,
show_mismatch: false,
};
assert_eq!(board2.total_cols(), 0);
}
fn write_issue_md(
dir: &std::path::Path,
name: &str,
number: u32,
title: &str,
status: &str,
created: &str,
) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"{created}\"\nupdated: \"{created}\"\n---\n\nBody text."
);
std::fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn order_by_sort_desc_asc_none() {
let mk = |created: &str| Card {
number: 1,
title: created.to_string(),
priority: crate::model::Priority::Medium,
labels: vec![],
assignee: None,
filename: "x.md".into(),
commit: None,
created: created.to_string(),
};
let dates =
|cards: &[Card]| -> Vec<String> { cards.iter().map(|c| c.created.clone()).collect() };
let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
crate::config::OrderBy::CreatedAtDesc.sort(&mut cards);
assert_eq!(dates(&cards), ["2026-03-01", "2026-02-01", "2026-01-01"]);
let mut cards = vec![mk("2026-01-01"), mk("2026-03-01"), mk("2026-02-01")];
crate::config::OrderBy::CreatedAtAsc.sort(&mut cards);
assert_eq!(dates(&cards), ["2026-01-01", "2026-02-01", "2026-03-01"]);
let mut cards = vec![mk("2026-03-01"), mk("2026-01-01")];
crate::config::OrderBy::None.sort(&mut cards);
assert_eq!(
dates(&cards),
["2026-03-01", "2026-01-01"],
"none is a no-op"
);
}
#[test]
fn build_orders_most_recent_first_by_default() {
use crate::config::Config;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
let config = Config {
issues_dir: PathBuf::from(&dir),
..Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let board = BoardState::build(&store, config.default_config());
let titles: Vec<&str> = board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, ["new", "mid", "old"], "default desc: newest first");
}
#[test]
fn build_applies_created_at_asc_and_none() {
use crate::config::OrderBy;
use std::path::PathBuf;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
write_issue_md(&dir, "2026-01-01-old.md", 1, "old", "todo", "2026-01-01");
write_issue_md(&dir, "2026-02-01-mid.md", 2, "mid", "todo", "2026-02-01");
write_issue_md(&dir, "2026-03-01-new.md", 3, "new", "todo", "2026-03-01");
let config = crate::config::Config {
issues_dir: PathBuf::from(&dir),
..crate::config::Config::default()
};
let store = crate::storage::Store::open(&config).unwrap();
let mut cfg_asc = config.clone();
cfg_asc
.kanban
.configurations
.get_mut("default")
.unwrap()
.order_by = OrderBy::CreatedAtAsc;
let board_asc = BoardState::build(&store, cfg_asc.default_config());
let titles: Vec<&str> = board_asc
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(titles, ["old", "mid", "new"]);
let mut cfg_none = config;
cfg_none
.kanban
.configurations
.get_mut("default")
.unwrap()
.order_by = OrderBy::None;
let board_none = BoardState::build(&store, cfg_none.default_config());
let titles: Vec<&str> = board_none
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(
titles,
["old", "mid", "new"],
"none keeps the store's filename-ascending order"
);
}
#[test]
fn build_filtered_applies_order_by() {
use crate::model::Issue;
use crate::search::{Corpus, StructuredFilter};
let to_issue = |number: u32, title: &str, created: &str| Issue {
number,
title: title.to_string(),
status: crate::model::Status::new("todo"),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: created.into(),
updated: "2026-01-01".into(),
filename: format!("2026-01-01-{number}.md"),
body: String::new(),
};
let corpus = Corpus {
issues: vec![
to_issue(1, "old", "2026-01-01"),
to_issue(2, "new", "2026-03-01"),
to_issue(3, "mid", "2026-02-01"),
],
texts: vec!["old".into(), "new".into(), "mid".into()],
};
let config = crate::config::Config::default();
let board = BoardState::build_filtered(
config.default_config(),
&corpus,
"",
&StructuredFilter::default(),
);
let titles: Vec<&str> = board
.column_cards(0)
.iter()
.map(|c| c.title.as_str())
.collect();
assert_eq!(
titles,
["new", "mid", "old"],
"default desc applies to filtered results too"
);
}
}