use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::config::{BoardConfiguration, OrderBy};
use crate::error::Result;
use crate::records::{self, HasName, RecordStore};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoardRecord {
pub name: String,
pub columns: Vec<String>,
pub done_column: Option<String>,
pub category_map: IndexMap<String, String>,
pub hidden_columns: Vec<String>,
pub show_mismatch: bool,
pub wrap_titles: bool,
pub title_lines: usize,
pub card_order: Vec<String>,
pub order_by: OrderBy,
}
impl Default for BoardRecord {
fn default() -> Self {
let board = BoardConfiguration::default();
Self {
name: String::new(),
columns: board.columns,
done_column: board.done_column,
category_map: board.category_map,
hidden_columns: board.hidden_columns,
show_mismatch: board.show_mismatch,
wrap_titles: board.wrap_titles,
title_lines: board.title_lines,
card_order: board.card_order,
order_by: board.order_by,
}
}
}
impl HasName for BoardRecord {
fn name(&self) -> &str {
&self.name
}
}
impl From<BoardRecord> for BoardConfiguration {
fn from(record: BoardRecord) -> Self {
Self {
columns: record.columns,
done_column: record.done_column,
category_map: record.category_map,
hidden_columns: record.hidden_columns,
show_mismatch: record.show_mismatch,
wrap_titles: record.wrap_titles,
title_lines: record.title_lines,
card_order: record.card_order,
order_by: record.order_by,
wip: crate::wip::WipConfig::default(),
}
}
}
pub fn board_record(name: &str, board: &BoardConfiguration) -> BoardRecord {
BoardRecord {
name: name.to_string(),
columns: board.columns.clone(),
done_column: board.done_column.clone(),
category_map: board.category_map.clone(),
hidden_columns: board.hidden_columns.clone(),
show_mismatch: board.show_mismatch,
wrap_titles: board.wrap_titles,
title_lines: board.title_lines,
card_order: board.card_order.clone(),
order_by: board.order_by,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardStore {
dir: PathBuf,
}
impl BoardStore {
pub fn new(root: &Path) -> Self {
Self {
dir: root.join("docs/boards"),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn exists(&self) -> bool {
self.dir.is_dir()
}
pub fn list(&self) -> Result<Vec<(String, BoardConfiguration)>> {
if !self.exists() {
return Ok(Vec::new());
}
let mut boards = Vec::new();
for entry in std::fs::read_dir(&self.dir)? {
let Ok(entry) = entry else { continue };
if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(contents) = std::fs::read_to_string(entry.path()) else {
continue;
};
let Some((frontmatter, _body)) = records::split_frontmatter(&contents) else {
continue;
};
let Ok(record) = serde_yaml::from_str::<BoardRecord>(frontmatter) else {
continue;
};
let name = if record.name.trim().is_empty() {
entry
.path()
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default()
.to_string()
} else {
record.name.clone()
};
boards.push((name, BoardConfiguration::from(record)));
}
boards.sort_by(|a, b| a.0.cmp(&b.0));
Ok(boards)
}
pub fn save(&self, name: &str, board: &BoardConfiguration, body: Option<&str>) -> Result<()> {
let filename = format!("{}.md", crate::slug::slugify(name));
let record = board_record(name, board);
RecordStore::new(&self.dir).overwrite(&filename, &record, body)?;
Ok(())
}
pub fn get(&self, name: &str) -> Result<BoardRecord> {
RecordStore::new(&self.dir)
.get::<BoardRecord>(name)
.map(|record| record.value)
}
pub fn rename(&self, old: &str, new: &str) -> Result<()> {
if old == new {
return Ok(());
}
let store = RecordStore::new(&self.dir);
let record = store.get::<BoardRecord>(old)?;
let new_filename = format!("{}.md", crate::slug::slugify(new));
if self.dir.join(&new_filename).exists() {
return Err(crate::error::TixError::InvalidInput(format!(
"board `{new}` already exists"
)));
}
let value = BoardRecord {
name: new.to_string(),
..record.value
};
store.overwrite(&new_filename, &value, Some(&record.body))?;
store.delete_file(&record.filename)?;
Ok(())
}
pub fn delete(&self, name: &str) -> Result<()> {
let store = RecordStore::new(&self.dir);
let record = store.get::<BoardRecord>(name)?;
store.delete_file(&record.filename)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::config::{Config, starter_board};
#[test]
fn board_loads_from_its_file() {
let tmp = TempDir::new().unwrap();
let store = BoardStore::new(tmp.path());
assert!(!store.exists());
assert_eq!(
store.list().unwrap(),
[] as [(std::string::String, crate::config::BoardConfiguration); 0]
);
let dir = tmp.path().join("docs/boards");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("review.md"),
"---\nname: review\ncolumns:\n- todo\n- done\ndone_column: done\n\
category_map:\n done: completed\n---\n\nReview board.\n",
)
.unwrap();
let boards = store.list().unwrap();
assert_eq!(boards.len(), 1);
assert_eq!(boards[0].0, "review");
assert_eq!(boards[0].1.columns, ["todo", "done"]);
assert_eq!(boards[0].1.done_column.as_deref(), Some("done"));
assert_eq!(
boards[0].1.category_map.get("done").map(String::as_str),
Some("completed")
);
}
#[test]
fn board_name_falls_back_to_the_filename_stem() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs/boards");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("ops.md"), "---\ncolumns:\n- todo\n---\n\n").unwrap();
let boards = BoardStore::new(tmp.path()).list().unwrap();
assert_eq!(boards[0].0, "ops");
assert_eq!(boards[0].1.columns, ["todo"]);
}
#[test]
fn card_order_is_written_to_the_board_file_and_not_to_tix_toml() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let path = root.join("tix.toml");
let store = BoardStore::new(root);
store.save("default", &starter_board(), None).unwrap();
let mut config = Config::default();
config
.kanban
.configurations
.insert("default".to_string(), starter_board());
config.save(&path).unwrap();
let mut reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(reloaded.default_config().columns, starter_board().columns);
reloaded
.kanban
.configurations
.get_mut("default")
.unwrap()
.card_order = vec!["2026-01-01-b.md".into(), "2026-01-01-a.md".into()];
reloaded.save(&path).unwrap();
let board_raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
assert!(
board_raw.contains("card_order:"),
"card order lives in the board file:\n{board_raw}"
);
assert!(board_raw.contains("2026-01-01-b.md"), "{board_raw}");
let toml_raw = fs::read_to_string(&path).unwrap();
assert!(
!toml_raw.contains("[kanban"),
"boards must not be duplicated into tix.toml:\n{toml_raw}"
);
assert!(
!toml_raw.contains("2026-01-01-b.md"),
"card order must not be in tix.toml:\n{toml_raw}"
);
let again = Config::load_with_warning(&path, |_| {}).unwrap();
assert_eq!(
again.default_config().card_order,
["2026-01-01-b.md", "2026-01-01-a.md"]
);
}
#[test]
fn config_save_preserves_a_hand_written_board_body() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let path = root.join("tix.toml");
BoardStore::new(root)
.save("default", &starter_board(), Some("Keep these notes"))
.unwrap();
let mut config = Config::default();
config
.kanban
.configurations
.insert("default".to_string(), starter_board());
config.save(&path).unwrap();
let raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
assert!(
raw.ends_with("Keep these notes\n"),
"a config save must not discard the board body:\n{raw}"
);
}
#[test]
fn rename_moves_the_file_and_keeps_the_body_and_fields() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let store = BoardStore::new(root);
let mut board = starter_board();
board.hidden_columns = vec!["wontfix".into()];
store.save("default", &board, Some("Ops notes")).unwrap();
store.rename("default", "ops board").unwrap();
assert!(
!root.join("docs/boards/default.md").exists(),
"the old file is gone"
);
let record = store.get("ops board").unwrap();
assert_eq!(record.name, "ops board");
assert_eq!(record.hidden_columns, ["wontfix"]);
let raw = fs::read_to_string(root.join("docs/boards/ops-board.md")).unwrap();
assert!(
raw.ends_with("Ops notes\n"),
"body survives the rename:\n{raw}"
);
assert!(store.get("default").is_err(), "the old name is not stored");
}
#[test]
fn rename_refuses_a_name_that_already_exists() {
let tmp = TempDir::new().unwrap();
let store = BoardStore::new(tmp.path());
store.save("default", &starter_board(), None).unwrap();
store.save("ops", &starter_board(), None).unwrap();
assert!(store.rename("default", "ops").is_err());
assert!(store.get("default").is_ok(), "nothing moved");
}
#[test]
fn delete_removes_only_the_named_board() {
let tmp = TempDir::new().unwrap();
let store = BoardStore::new(tmp.path());
store.save("default", &starter_board(), None).unwrap();
store.save("ops", &starter_board(), None).unwrap();
store.delete("ops").unwrap();
assert!(store.get("ops").is_err());
assert!(store.get("default").is_ok(), "the other board is untouched");
}
}