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 #[allow(clippy::wildcard_imports)]
228 use super::*;
229 use crate::config::{Config, starter_board};
230
231 #[test]
232 fn board_loads_from_its_file() {
233 let tmp = TempDir::new().unwrap();
234 let store = BoardStore::new(tmp.path());
235 assert!(!store.exists());
236 assert_eq!(
237 store.list().unwrap(),
238 [] as [(std::string::String, crate::config::BoardConfiguration); 0]
239 );
240
241 let dir = tmp.path().join("docs/boards");
242 fs::create_dir_all(&dir).unwrap();
243 fs::write(
244 dir.join("review.md"),
245 "---\nname: review\ncolumns:\n- todo\n- done\ndone_column: done\n\
246 category_map:\n done: completed\n---\n\nReview board.\n",
247 )
248 .unwrap();
249
250 let boards = store.list().unwrap();
251 assert_eq!(boards.len(), 1);
252 assert_eq!(boards[0].0, "review");
253 assert_eq!(boards[0].1.columns, ["todo", "done"]);
254 assert_eq!(boards[0].1.done_column.as_deref(), Some("done"));
255 assert_eq!(
256 boards[0].1.category_map.get("done").map(String::as_str),
257 Some("completed")
258 );
259 }
260
261 #[test]
262 fn board_name_falls_back_to_the_filename_stem() {
263 let tmp = TempDir::new().unwrap();
264 let dir = tmp.path().join("docs/boards");
265 fs::create_dir_all(&dir).unwrap();
266 fs::write(dir.join("ops.md"), "---\ncolumns:\n- todo\n---\n\n").unwrap();
267 let boards = BoardStore::new(tmp.path()).list().unwrap();
268 assert_eq!(boards[0].0, "ops");
269 assert_eq!(boards[0].1.columns, ["todo"]);
270 }
271
272 #[test]
273 fn card_order_is_written_to_the_board_file_and_not_to_tix_toml() {
274 let tmp = TempDir::new().unwrap();
275 let root = tmp.path();
276 let path = root.join("tix.toml");
277 let store = BoardStore::new(root);
279 store.save("default", &starter_board(), None).unwrap();
280
281 let mut config = Config::default();
282 config
283 .kanban
284 .configurations
285 .insert("default".to_string(), starter_board());
286 config.save(&path).unwrap();
287
288 let mut reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
289 assert_eq!(reloaded.default_config().columns, starter_board().columns);
290 reloaded
291 .kanban
292 .configurations
293 .get_mut("default")
294 .unwrap()
295 .card_order = vec!["2026-01-01-b.md".into(), "2026-01-01-a.md".into()];
296 reloaded.save(&path).unwrap();
297
298 let board_raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
299 assert!(
300 board_raw.contains("card_order:"),
301 "card order lives in the board file:\n{board_raw}"
302 );
303 assert!(board_raw.contains("2026-01-01-b.md"), "{board_raw}");
304 let toml_raw = fs::read_to_string(&path).unwrap();
305 assert!(
306 !toml_raw.contains("[kanban"),
307 "boards must not be duplicated into tix.toml:\n{toml_raw}"
308 );
309 assert!(
310 !toml_raw.contains("2026-01-01-b.md"),
311 "card order must not be in tix.toml:\n{toml_raw}"
312 );
313
314 let again = Config::load_with_warning(&path, |_| {}).unwrap();
316 assert_eq!(
317 again.default_config().card_order,
318 ["2026-01-01-b.md", "2026-01-01-a.md"]
319 );
320 }
321
322 #[test]
323 fn config_save_preserves_a_hand_written_board_body() {
324 let tmp = TempDir::new().unwrap();
325 let root = tmp.path();
326 let path = root.join("tix.toml");
327 BoardStore::new(root)
328 .save("default", &starter_board(), Some("Keep these notes"))
329 .unwrap();
330
331 let mut config = Config::default();
332 config
333 .kanban
334 .configurations
335 .insert("default".to_string(), starter_board());
336 config.save(&path).unwrap();
337
338 let raw = fs::read_to_string(root.join("docs/boards/default.md")).unwrap();
339 assert!(
340 raw.ends_with("Keep these notes\n"),
341 "a config save must not discard the board body:\n{raw}"
342 );
343 }
344
345 #[test]
346 fn rename_moves_the_file_and_keeps_the_body_and_fields() {
347 let tmp = TempDir::new().unwrap();
348 let root = tmp.path();
349 let store = BoardStore::new(root);
350 let mut board = starter_board();
351 board.hidden_columns = vec!["wontfix".into()];
352 store.save("default", &board, Some("Ops notes")).unwrap();
353
354 store.rename("default", "ops board").unwrap();
355
356 assert!(
357 !root.join("docs/boards/default.md").exists(),
358 "the old file is gone"
359 );
360 let record = store.get("ops board").unwrap();
361 assert_eq!(record.name, "ops board");
362 assert_eq!(record.hidden_columns, ["wontfix"]);
363 let raw = fs::read_to_string(root.join("docs/boards/ops-board.md")).unwrap();
364 assert!(
365 raw.ends_with("Ops notes\n"),
366 "body survives the rename:\n{raw}"
367 );
368 assert!(store.get("default").is_err(), "the old name is not stored");
369 }
370
371 #[test]
372 fn rename_refuses_a_name_that_already_exists() {
373 let tmp = TempDir::new().unwrap();
374 let store = BoardStore::new(tmp.path());
375 store.save("default", &starter_board(), None).unwrap();
376 store.save("ops", &starter_board(), None).unwrap();
377 assert!(store.rename("default", "ops").is_err());
378 assert!(store.get("default").is_ok(), "nothing moved");
379 }
380
381 #[test]
382 fn delete_removes_only_the_named_board() {
383 let tmp = TempDir::new().unwrap();
384 let store = BoardStore::new(tmp.path());
385 store.save("default", &starter_board(), None).unwrap();
386 store.save("ops", &starter_board(), None).unwrap();
387
388 store.delete("ops").unwrap();
389
390 assert!(store.get("ops").is_err());
391 assert!(store.get("default").is_ok(), "the other board is untouched");
392 }
393}