tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The board configuration store: `docs/boards/<name>.md`.
//!
//! A board was a `[kanban.<name>]` table in `tix.toml`. It is now a Markdown
//! file whose frontmatter carries the same keys, so the board layout is
//! human-editable next to the issues it describes. `tix.toml` keeps
//! everything else.
//!
//! The legacy fallback is mandatory: when `docs/boards/` does not exist the
//! caller reads the old `[kanban.*]` tables and nothing changes for an
//! existing project. When the directory *does* exist its files win.

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};

/// One board as stored in `docs/boards/<name>.md` frontmatter.
///
/// Field-for-field the persisted surface of [`BoardConfiguration`], plus the
/// `name` that gives the record its identity. Unknown keys are refused so a
/// typo in a hand-edited board file is reported rather than ignored.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BoardRecord {
    /// The board's name (identity; defaults to the filename stem when absent).
    pub name: String,
    /// Ordered, case-sensitive, non-empty, unique column names.
    pub columns: Vec<String>,
    /// The column treated as "done".
    pub done_column: Option<String>,
    /// Column name → category key.
    pub category_map: IndexMap<String, String>,
    /// Columns hidden from the rendered board.
    pub hidden_columns: Vec<String>,
    /// Whether the computed "Mismatched" pseudo-column is rendered.
    pub show_mismatch: bool,
    /// Whether long card titles wrap onto multiple lines.
    pub wrap_titles: bool,
    /// The maximum number of lines a wrapped card may occupy.
    pub title_lines: usize,
    /// Sparse manual card order persisted by drag-and-drop reordering.
    pub card_order: Vec<String>,
    /// Ordering of cards within each rendered column.
    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,
            // Board files do not carry limits yet; the config field stays empty and
            // an absent `wip` serializes to nothing.
            wip: crate::wip::WipConfig::default(),
        }
    }
}

/// The stored form of a board, named for its frontmatter.
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,
    }
}

/// A board store rooted at a project root (`root/docs/boards`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardStore {
    dir: PathBuf,
}

impl BoardStore {
    /// Bind the store to `root/docs/boards`; the directory is not created here.
    pub fn new(root: &Path) -> Self {
        Self {
            dir: root.join("docs/boards"),
        }
    }

    /// The directory backing this store.
    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// Whether `docs/boards/` exists. Its absence means "read legacy
    /// `tix.toml`" and is not an error.
    pub fn exists(&self) -> bool {
        self.dir.is_dir()
    }

    /// Every board file, sorted by filename. The name is the frontmatter
    /// `name`, falling back to the filename stem when the field is absent or
    /// blank. Unparseable files are skipped.
    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)
    }

    /// Write a board to `docs/boards/<slug(name)>.md`, replacing any file of
    /// that name. Used for card-order persistence and configuration edits.
    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(())
    }

    /// Look a stored board up by its frontmatter `name`.
    pub fn get(&self, name: &str) -> Result<BoardRecord> {
        RecordStore::new(&self.dir)
            .get::<BoardRecord>(name)
            .map(|record| record.value)
    }

    /// Rename a stored board, moving its file and rebasing the frontmatter
    /// `name`. The body is preserved. A name that is not stored is a
    /// [`crate::error::TixError::NotFound`].
    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(())
    }

    /// Delete a stored board by name.
    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;

    #[allow(clippy::wildcard_imports)]
    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");
        // Give the project a board file so the file store is authoritative.
        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}"
        );

        // And the order survives a reload from the file store.
        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");
    }
}