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 }
88 }
89}
90
91pub fn board_record(name: &str, board: &BoardConfiguration) -> BoardRecord {
93 BoardRecord {
94 name: name.to_string(),
95 columns: board.columns.clone(),
96 done_column: board.done_column.clone(),
97 category_map: board.category_map.clone(),
98 hidden_columns: board.hidden_columns.clone(),
99 show_mismatch: board.show_mismatch,
100 wrap_titles: board.wrap_titles,
101 title_lines: board.title_lines,
102 card_order: board.card_order.clone(),
103 order_by: board.order_by,
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct BoardStore {
110 dir: PathBuf,
111}
112
113impl BoardStore {
114 pub fn new(root: &Path) -> Self {
116 Self {
117 dir: root.join("docs/boards"),
118 }
119 }
120
121 pub fn dir(&self) -> &Path {
123 &self.dir
124 }
125
126 pub fn exists(&self) -> bool {
129 self.dir.is_dir()
130 }
131
132 pub fn list(&self) -> Result<Vec<(String, BoardConfiguration)>> {
136 if !self.exists() {
137 return Ok(Vec::new());
138 }
139 let mut boards = Vec::new();
140 for entry in std::fs::read_dir(&self.dir)? {
141 let Ok(entry) = entry else { continue };
142 if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
143 continue;
144 }
145 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
146 continue;
147 };
148 let Some((frontmatter, _body)) = records::split_frontmatter(&contents) else {
149 continue;
150 };
151 let Ok(record) = serde_yaml::from_str::<BoardRecord>(frontmatter) else {
152 continue;
153 };
154 let name = if record.name.trim().is_empty() {
155 entry
156 .path()
157 .file_stem()
158 .and_then(|stem| stem.to_str())
159 .unwrap_or_default()
160 .to_string()
161 } else {
162 record.name.clone()
163 };
164 boards.push((name, BoardConfiguration::from(record)));
165 }
166 boards.sort_by(|a, b| a.0.cmp(&b.0));
167 Ok(boards)
168 }
169
170 pub fn save(&self, name: &str, board: &BoardConfiguration, body: Option<&str>) -> Result<()> {
173 let filename = format!("{}.md", crate::slug::slugify(name));
174 let record = board_record(name, board);
175 RecordStore::new(&self.dir).overwrite(&filename, &record, body)?;
176 Ok(())
177 }
178
179 pub fn get(&self, name: &str) -> Result<BoardRecord> {
181 RecordStore::new(&self.dir)
182 .get::<BoardRecord>(name)
183 .map(|record| record.value)
184 }
185
186 pub fn rename(&self, old: &str, new: &str) -> Result<()> {
190 if old == new {
191 return Ok(());
192 }
193 let store = RecordStore::new(&self.dir);
194 let record = store.get::<BoardRecord>(old)?;
195 let new_filename = format!("{}.md", crate::slug::slugify(new));
196 if self.dir.join(&new_filename).exists() {
197 return Err(crate::error::TixError::InvalidInput(format!(
198 "board `{new}` already exists"
199 )));
200 }
201 let value = BoardRecord {
202 name: new.to_string(),
203 ..record.value
204 };
205 store.overwrite(&new_filename, &value, Some(&record.body))?;
206 store.delete_file(&record.filename)?;
207 Ok(())
208 }
209
210 pub fn delete(&self, name: &str) -> Result<()> {
212 let store = RecordStore::new(&self.dir);
213 let record = store.get::<BoardRecord>(name)?;
214 store.delete_file(&record.filename)
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use std::fs;
221
222 use tempfile::TempDir;
223
224 use super::*;
225 use crate::config::{Config, starter_board};
226
227 #[test]
228 fn board_loads_from_its_file() {
229 let tmp = TempDir::new().unwrap();
230 let store = BoardStore::new(tmp.path());
231 assert!(!store.exists());
232 assert!(store.list().unwrap().is_empty());
233
234 let dir = tmp.path().join("docs/boards");
235 fs::create_dir_all(&dir).unwrap();
236 fs::write(
237 dir.join("review.md"),
238 "---\nname: review\ncolumns:\n- todo\n- done\ndone_column: done\n\
239 category_map:\n done: completed\n---\n\nReview board.\n",
240 )
241 .unwrap();
242
243 let boards = store.list().unwrap();
244 assert_eq!(boards.len(), 1);
245 assert_eq!(boards[0].0, "review");
246 assert_eq!(boards[0].1.columns, ["todo", "done"]);
247 assert_eq!(boards[0].1.done_column.as_deref(), Some("done"));
248 assert_eq!(
249 boards[0].1.category_map.get("done").map(String::as_str),
250 Some("completed")
251 );
252 }
253
254 #[test]
255 fn board_name_falls_back_to_the_filename_stem() {
256 let tmp = TempDir::new().unwrap();
257 let dir = tmp.path().join("docs/boards");
258 fs::create_dir_all(&dir).unwrap();
259 fs::write(dir.join("ops.md"), "---\ncolumns:\n- todo\n---\n\n").unwrap();
260 let boards = BoardStore::new(tmp.path()).list().unwrap();
261 assert_eq!(boards[0].0, "ops");
262 assert_eq!(boards[0].1.columns, ["todo"]);
263 }
264
265 #[test]
266 fn card_order_is_written_to_the_board_file_and_not_to_tix_toml() {
267 let tmp = TempDir::new().unwrap();
268 let root = tmp.path();
269 let path = root.join("tix.toml");
270 let store = BoardStore::new(root);
272 store.save("default", &starter_board(), None).unwrap();
273
274 let mut config = Config::default();
275 config
276 .kanban
277 .configurations
278 .insert("default".to_string(), starter_board());
279 config.save(&path).unwrap();
280
281 let mut reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
282 assert_eq!(reloaded.default_config().columns, starter_board().columns);
283 reloaded
284 .kanban
285 .configurations
286 .get_mut("default")
287 .unwrap()
288 .card_order = vec!["2026-01-01-b.md".into(), "2026-01-01-a.md".into()];
289 reloaded.save(&path).unwrap();
290
291 let board_raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
292 assert!(
293 board_raw.contains("card_order:"),
294 "card order lives in the board file:\n{board_raw}"
295 );
296 assert!(board_raw.contains("2026-01-01-b.md"), "{board_raw}");
297 let toml_raw = fs::read_to_string(&path).unwrap();
298 assert!(
299 !toml_raw.contains("[kanban"),
300 "boards must not be duplicated into tix.toml:\n{toml_raw}"
301 );
302 assert!(
303 !toml_raw.contains("2026-01-01-b.md"),
304 "card order must not be in tix.toml:\n{toml_raw}"
305 );
306
307 let again = Config::load_with_warning(&path, |_| {}).unwrap();
309 assert_eq!(
310 again.default_config().card_order,
311 ["2026-01-01-b.md", "2026-01-01-a.md"]
312 );
313 }
314
315 #[test]
316 fn config_save_preserves_a_hand_written_board_body() {
317 let tmp = TempDir::new().unwrap();
318 let root = tmp.path();
319 let path = root.join("tix.toml");
320 BoardStore::new(root)
321 .save("default", &starter_board(), Some("Keep these notes"))
322 .unwrap();
323
324 let mut config = Config::default();
325 config
326 .kanban
327 .configurations
328 .insert("default".to_string(), starter_board());
329 config.save(&path).unwrap();
330
331 let raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
332 assert!(
333 raw.ends_with("Keep these notes\n"),
334 "a config save must not discard the board body:\n{raw}"
335 );
336 }
337
338 #[test]
339 fn rename_moves_the_file_and_keeps_the_body_and_fields() {
340 let tmp = TempDir::new().unwrap();
341 let root = tmp.path();
342 let store = BoardStore::new(root);
343 let mut board = starter_board();
344 board.hidden_columns = vec!["wontfix".into()];
345 store.save("default", &board, Some("Ops notes")).unwrap();
346
347 store.rename("default", "ops board").unwrap();
348
349 assert!(
350 !root.join("docs/boards/default.md").exists(),
351 "the old file is gone"
352 );
353 let record = store.get("ops board").unwrap();
354 assert_eq!(record.name, "ops board");
355 assert_eq!(record.hidden_columns, ["wontfix"]);
356 let raw = fs::read_to_string(root.join("docs/boards/ops-board.md")).unwrap();
357 assert!(
358 raw.ends_with("Ops notes\n"),
359 "body survives the rename:\n{raw}"
360 );
361 assert!(store.get("default").is_err(), "the old name is not stored");
362 }
363
364 #[test]
365 fn rename_refuses_a_name_that_already_exists() {
366 let tmp = TempDir::new().unwrap();
367 let store = BoardStore::new(tmp.path());
368 store.save("default", &starter_board(), None).unwrap();
369 store.save("ops", &starter_board(), None).unwrap();
370 assert!(store.rename("default", "ops").is_err());
371 assert!(store.get("default").is_ok(), "nothing moved");
372 }
373
374 #[test]
375 fn delete_removes_only_the_named_board() {
376 let tmp = TempDir::new().unwrap();
377 let store = BoardStore::new(tmp.path());
378 store.save("default", &starter_board(), None).unwrap();
379 store.save("ops", &starter_board(), None).unwrap();
380
381 store.delete("ops").unwrap();
382
383 assert!(store.get("ops").is_err());
384 assert!(store.get("default").is_ok(), "the other board is untouched");
385 }
386}