1use std::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use crate::config::{BoardConfiguration, OrderBy};
18use crate::error::Result;
19use crate::records::{self, HasName, RecordStore};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct BoardRecord {
29 pub name: String,
31 pub columns: Vec<String>,
33 pub done_column: Option<String>,
35 pub category_map: IndexMap<String, String>,
37 pub hidden_columns: Vec<String>,
39 pub show_mismatch: bool,
41 pub wrap_titles: bool,
43 pub title_lines: usize,
45 pub card_order: Vec<String>,
47 pub order_by: OrderBy,
49}
50
51impl Default for BoardRecord {
52 fn default() -> Self {
53 let board = BoardConfiguration::default();
54 Self {
55 name: String::new(),
56 columns: board.columns,
57 done_column: board.done_column,
58 category_map: board.category_map,
59 hidden_columns: board.hidden_columns,
60 show_mismatch: board.show_mismatch,
61 wrap_titles: board.wrap_titles,
62 title_lines: board.title_lines,
63 card_order: board.card_order,
64 order_by: board.order_by,
65 }
66 }
67}
68
69impl HasName for BoardRecord {
70 fn name(&self) -> &str {
71 &self.name
72 }
73}
74
75impl From<BoardRecord> for BoardConfiguration {
76 fn from(record: BoardRecord) -> Self {
77 Self {
78 columns: record.columns,
79 done_column: record.done_column,
80 category_map: record.category_map,
81 hidden_columns: record.hidden_columns,
82 show_mismatch: record.show_mismatch,
83 wrap_titles: record.wrap_titles,
84 title_lines: record.title_lines,
85 card_order: record.card_order,
86 order_by: record.order_by,
87 wip: crate::wip::WipConfig::default(),
90 }
91 }
92}
93
94pub fn board_record(name: &str, board: &BoardConfiguration) -> BoardRecord {
96 BoardRecord {
97 name: name.to_string(),
98 columns: board.columns.clone(),
99 done_column: board.done_column.clone(),
100 category_map: board.category_map.clone(),
101 hidden_columns: board.hidden_columns.clone(),
102 show_mismatch: board.show_mismatch,
103 wrap_titles: board.wrap_titles,
104 title_lines: board.title_lines,
105 card_order: board.card_order.clone(),
106 order_by: board.order_by,
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct BoardStore {
113 dir: PathBuf,
114}
115
116impl BoardStore {
117 pub fn new(root: &Path) -> Self {
119 Self {
120 dir: root.join("docs/boards"),
121 }
122 }
123
124 pub fn dir(&self) -> &Path {
126 &self.dir
127 }
128
129 pub fn exists(&self) -> bool {
132 self.dir.is_dir()
133 }
134
135 pub fn list(&self) -> Result<Vec<(String, BoardConfiguration)>> {
139 if !self.exists() {
140 return Ok(Vec::new());
141 }
142 let mut boards = Vec::new();
143 for entry in std::fs::read_dir(&self.dir)? {
144 let Ok(entry) = entry else { continue };
145 if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
146 continue;
147 }
148 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
149 continue;
150 };
151 let Some((frontmatter, _body)) = records::split_frontmatter(&contents) else {
152 continue;
153 };
154 let Ok(record) = serde_yaml::from_str::<BoardRecord>(frontmatter) else {
155 continue;
156 };
157 let name = if record.name.trim().is_empty() {
158 entry
159 .path()
160 .file_stem()
161 .and_then(|stem| stem.to_str())
162 .unwrap_or_default()
163 .to_string()
164 } else {
165 record.name.clone()
166 };
167 boards.push((name, BoardConfiguration::from(record)));
168 }
169 boards.sort_by(|a, b| a.0.cmp(&b.0));
170 Ok(boards)
171 }
172
173 pub fn save(&self, name: &str, board: &BoardConfiguration, body: Option<&str>) -> Result<()> {
176 let filename = format!("{}.md", crate::slug::slugify(name));
177 let record = board_record(name, board);
178 RecordStore::new(&self.dir).overwrite(&filename, &record, body)?;
179 Ok(())
180 }
181
182 pub fn get(&self, name: &str) -> Result<BoardRecord> {
184 RecordStore::new(&self.dir)
185 .get::<BoardRecord>(name)
186 .map(|record| record.value)
187 }
188
189 pub fn rename(&self, old: &str, new: &str) -> Result<()> {
193 if old == new {
194 return Ok(());
195 }
196 let store = RecordStore::new(&self.dir);
197 let record = store.get::<BoardRecord>(old)?;
198 let new_filename = format!("{}.md", crate::slug::slugify(new));
199 if self.dir.join(&new_filename).exists() {
200 return Err(crate::error::TixError::InvalidInput(format!(
201 "board `{new}` already exists"
202 )));
203 }
204 let value = BoardRecord {
205 name: new.to_string(),
206 ..record.value
207 };
208 store.overwrite(&new_filename, &value, Some(&record.body))?;
209 store.delete_file(&record.filename)?;
210 Ok(())
211 }
212
213 pub fn delete(&self, name: &str) -> Result<()> {
215 let store = RecordStore::new(&self.dir);
216 let record = store.get::<BoardRecord>(name)?;
217 store.delete_file(&record.filename)
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use std::fs;
224
225 use tempfile::TempDir;
226
227 use super::*;
228 use crate::config::{Config, starter_board};
229
230 #[test]
231 fn board_loads_from_its_file() {
232 let tmp = TempDir::new().unwrap();
233 let store = BoardStore::new(tmp.path());
234 assert!(!store.exists());
235 assert_eq!(
236 store.list().unwrap(),
237 [] as [(std::string::String, crate::config::BoardConfiguration); 0]
238 );
239
240 let dir = tmp.path().join("docs/boards");
241 fs::create_dir_all(&dir).unwrap();
242 fs::write(
243 dir.join("review.md"),
244 "---\nname: review\ncolumns:\n- todo\n- done\ndone_column: done\n\
245 category_map:\n done: completed\n---\n\nReview board.\n",
246 )
247 .unwrap();
248
249 let boards = store.list().unwrap();
250 assert_eq!(boards.len(), 1);
251 assert_eq!(boards[0].0, "review");
252 assert_eq!(boards[0].1.columns, ["todo", "done"]);
253 assert_eq!(boards[0].1.done_column.as_deref(), Some("done"));
254 assert_eq!(
255 boards[0].1.category_map.get("done").map(String::as_str),
256 Some("completed")
257 );
258 }
259
260 #[test]
261 fn board_name_falls_back_to_the_filename_stem() {
262 let tmp = TempDir::new().unwrap();
263 let dir = tmp.path().join("docs/boards");
264 fs::create_dir_all(&dir).unwrap();
265 fs::write(dir.join("ops.md"), "---\ncolumns:\n- todo\n---\n\n").unwrap();
266 let boards = BoardStore::new(tmp.path()).list().unwrap();
267 assert_eq!(boards[0].0, "ops");
268 assert_eq!(boards[0].1.columns, ["todo"]);
269 }
270
271 #[test]
272 fn card_order_is_written_to_the_board_file_and_not_to_tix_toml() {
273 let tmp = TempDir::new().unwrap();
274 let root = tmp.path();
275 let path = root.join("tix.toml");
276 let store = BoardStore::new(root);
278 store.save("default", &starter_board(), None).unwrap();
279
280 let mut config = Config::default();
281 config
282 .kanban
283 .configurations
284 .insert("default".to_string(), starter_board());
285 config.save(&path).unwrap();
286
287 let mut reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
288 assert_eq!(reloaded.default_config().columns, starter_board().columns);
289 reloaded
290 .kanban
291 .configurations
292 .get_mut("default")
293 .unwrap()
294 .card_order = vec!["2026-01-01-b.md".into(), "2026-01-01-a.md".into()];
295 reloaded.save(&path).unwrap();
296
297 let board_raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
298 assert!(
299 board_raw.contains("card_order:"),
300 "card order lives in the board file:\n{board_raw}"
301 );
302 assert!(board_raw.contains("2026-01-01-b.md"), "{board_raw}");
303 let toml_raw = fs::read_to_string(&path).unwrap();
304 assert!(
305 !toml_raw.contains("[kanban"),
306 "boards must not be duplicated into tix.toml:\n{toml_raw}"
307 );
308 assert!(
309 !toml_raw.contains("2026-01-01-b.md"),
310 "card order must not be in tix.toml:\n{toml_raw}"
311 );
312
313 let again = Config::load_with_warning(&path, |_| {}).unwrap();
315 assert_eq!(
316 again.default_config().card_order,
317 ["2026-01-01-b.md", "2026-01-01-a.md"]
318 );
319 }
320
321 #[test]
322 fn config_save_preserves_a_hand_written_board_body() {
323 let tmp = TempDir::new().unwrap();
324 let root = tmp.path();
325 let path = root.join("tix.toml");
326 BoardStore::new(root)
327 .save("default", &starter_board(), Some("Keep these notes"))
328 .unwrap();
329
330 let mut config = Config::default();
331 config
332 .kanban
333 .configurations
334 .insert("default".to_string(), starter_board());
335 config.save(&path).unwrap();
336
337 let raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
338 assert!(
339 raw.ends_with("Keep these notes\n"),
340 "a config save must not discard the board body:\n{raw}"
341 );
342 }
343
344 #[test]
345 fn rename_moves_the_file_and_keeps_the_body_and_fields() {
346 let tmp = TempDir::new().unwrap();
347 let root = tmp.path();
348 let store = BoardStore::new(root);
349 let mut board = starter_board();
350 board.hidden_columns = vec!["wontfix".into()];
351 store.save("default", &board, Some("Ops notes")).unwrap();
352
353 store.rename("default", "ops board").unwrap();
354
355 assert!(
356 !root.join("docs/boards/default.md").exists(),
357 "the old file is gone"
358 );
359 let record = store.get("ops board").unwrap();
360 assert_eq!(record.name, "ops board");
361 assert_eq!(record.hidden_columns, ["wontfix"]);
362 let raw = fs::read_to_string(root.join("docs/boards/ops-board.md")).unwrap();
363 assert!(
364 raw.ends_with("Ops notes\n"),
365 "body survives the rename:\n{raw}"
366 );
367 assert!(store.get("default").is_err(), "the old name is not stored");
368 }
369
370 #[test]
371 fn rename_refuses_a_name_that_already_exists() {
372 let tmp = TempDir::new().unwrap();
373 let store = BoardStore::new(tmp.path());
374 store.save("default", &starter_board(), None).unwrap();
375 store.save("ops", &starter_board(), None).unwrap();
376 assert!(store.rename("default", "ops").is_err());
377 assert!(store.get("default").is_ok(), "nothing moved");
378 }
379
380 #[test]
381 fn delete_removes_only_the_named_board() {
382 let tmp = TempDir::new().unwrap();
383 let store = BoardStore::new(tmp.path());
384 store.save("default", &starter_board(), None).unwrap();
385 store.save("ops", &starter_board(), None).unwrap();
386
387 store.delete("ops").unwrap();
388
389 assert!(store.get("ops").is_err());
390 assert!(store.get("default").is_ok(), "the other board is untouched");
391 }
392}