Skip to main content

tix/
board_store.rs

1//! The board configuration store: `docs/boards/<name>.md`.
2//!
3//! A board was a `[kanban.<name>]` table in `tix.toml`. It is now a Markdown
4//! file whose frontmatter carries the same keys, so the board layout is
5//! human-editable next to the issues it describes. `tix.toml` keeps
6//! everything else.
7//!
8//! The legacy fallback is mandatory: when `docs/boards/` does not exist the
9//! caller reads the old `[kanban.*]` tables and nothing changes for an
10//! existing project. When the directory *does* exist its files win.
11
12use 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/// One board as stored in `docs/boards/<name>.md` frontmatter.
22///
23/// Field-for-field the persisted surface of [`BoardConfiguration`], plus the
24/// `name` that gives the record its identity. Unknown keys are refused so a
25/// typo in a hand-edited board file is reported rather than ignored.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct BoardRecord {
29    /// The board's name (identity; defaults to the filename stem when absent).
30    pub name: String,
31    /// Ordered, case-sensitive, non-empty, unique column names.
32    pub columns: Vec<String>,
33    /// The column treated as "done".
34    pub done_column: Option<String>,
35    /// Column name → category key.
36    pub category_map: IndexMap<String, String>,
37    /// Columns hidden from the rendered board.
38    pub hidden_columns: Vec<String>,
39    /// Whether the computed "Mismatched" pseudo-column is rendered.
40    pub show_mismatch: bool,
41    /// Whether long card titles wrap onto multiple lines.
42    pub wrap_titles: bool,
43    /// The maximum number of lines a wrapped card may occupy.
44    pub title_lines: usize,
45    /// Sparse manual card order persisted by drag-and-drop reordering.
46    pub card_order: Vec<String>,
47    /// Ordering of cards within each rendered column.
48    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            // Board files do not carry limits yet; the config field stays empty and
88            // an absent `wip` serializes to nothing.
89            wip: crate::wip::WipConfig::default(),
90        }
91    }
92}
93
94/// The stored form of a board, named for its frontmatter.
95pub 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/// A board store rooted at a project root (`root/docs/boards`).
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct BoardStore {
113    dir: PathBuf,
114}
115
116impl BoardStore {
117    /// Bind the store to `root/docs/boards`; the directory is not created here.
118    pub fn new(root: &Path) -> Self {
119        Self {
120            dir: root.join("docs/boards"),
121        }
122    }
123
124    /// The directory backing this store.
125    pub fn dir(&self) -> &Path {
126        &self.dir
127    }
128
129    /// Whether `docs/boards/` exists. Its absence means "read legacy
130    /// `tix.toml`" and is not an error.
131    pub fn exists(&self) -> bool {
132        self.dir.is_dir()
133    }
134
135    /// Every board file, sorted by filename. The name is the frontmatter
136    /// `name`, falling back to the filename stem when the field is absent or
137    /// blank. Unparseable files are skipped.
138    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    /// Write a board to `docs/boards/<slug(name)>.md`, replacing any file of
174    /// that name. Used for card-order persistence and configuration edits.
175    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    /// Look a stored board up by its frontmatter `name`.
183    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    /// Rename a stored board, moving its file and rebasing the frontmatter
190    /// `name`. The body is preserved. A name that is not stored is a
191    /// [`crate::error::TixError::NotFound`].
192    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    /// Delete a stored board by name.
214    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        // Give the project a board file so the file store is authoritative.
278        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        // And the order survives a reload from the file store.
315        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}