tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use crate::config::Config;
use crate::error::Result;
use crate::storage::Store;

/// The board summary (FR-009): one `(column, count)` pair per configured
/// column and the total issue count, computed by the [`board`] core for the
/// `board_status` MCP tool (WP04) and rendered by the CLI [`run`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardSummary {
    /// Each configured column with its issue count, in config order.
    pub columns: Vec<(String, usize)>,
    /// Total number of issues in the store.
    pub total: usize,
}

/// `tix status`: the board summary (`contracts/cli.md`, FR-009) — a header
/// row, one row per configured column with its issue count, and a `Total`
/// row. Columns separated by exactly two spaces.
pub fn run(config: &Config, store: &Store) -> Result<()> {
    let summary = board(config, store)?;

    let column_width = config
        .default_config()
        .columns
        .iter()
        .map(|c| c.chars().count())
        .chain([6, 5])
        .max()
        .unwrap()
        .min(30);
    let count_width = summary.total.to_string().len().max(5).max(1);

    let row =
        |column: String, count: String| format!("{column:<column_width$}  {count:<count_width$}");

    println!("{}", row("COLUMN".into(), "COUNT".into()));
    for (column, count) in &summary.columns {
        println!("{}", row(column.clone(), count.to_string()));
    }
    println!("{}", row("Total".into(), summary.total.to_string()));
    Ok(())
}

/// The status core (WP03): count issues per configured column plus the total.
///
/// This is what MCP's `board_status` tool (WP04) calls. Side-effect policy
/// (C-005): the core returns the [`BoardSummary`] data structure; the CLI
/// renders it in [`run`] with the exact layout as before.
pub fn board(config: &Config, store: &Store) -> Result<BoardSummary> {
    let issues = store.list()?;
    let total = issues.len();
    let columns = config
        .default_config()
        .columns
        .iter()
        .map(|column| {
            let count = issues.iter().filter(|i| i.status.is(column)).count();
            (column.clone(), count)
        })
        .collect();
    Ok(BoardSummary { columns, total })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Issue, Priority, Status};
    use std::fs;
    use tempfile::TempDir;

    fn make_store() -> (TempDir, Store) {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path().join("issues");
        fs::create_dir_all(&dir).unwrap();
        let config_path = tmp.path().join("tix.toml");
        let config = crate::config::Config {
            issues_dir: dir.clone(),
            ..crate::config::Config::default()
        };
        config.save(&config_path).unwrap();
        let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
        (
            tmp,
            Store {
                issues_dir: dir,
                config,
            },
        )
    }

    fn seed(store: &Store, number: u32, title: &str, status: &str) {
        let mut issue = Issue {
            number,
            title: title.to_string(),
            status: Status::new(status.to_string()),
            labels: vec![],
            priority: Priority::default(),
            assignee: Some("alice".to_string()),
            estimate: None,
            commit: None,
            gitlab_id: None,
            linear_id: None,
            mission: None,
            task: None,
            created: "2026-01-01".to_string(),
            updated: "2026-01-01".to_string(),
            filename: String::new(),
            body: "body".to_string(),
        };
        issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
        fs::write(
            store.issues_dir.join(&issue.filename),
            issue.to_file_contents(),
        )
        .unwrap();
    }

    #[test]
    fn board_counts_columns_and_total() {
        let (tmp, store) = make_store();
        let _ = tmp;
        seed(&store, 1, "One", "todo");
        seed(&store, 2, "Two", "todo");
        seed(&store, 3, "Three", "in progress");
        seed(&store, 4, "Four", "done");
        let summary = board(&store.config, &store).unwrap();
        assert_eq!(
            summary
                .columns
                .iter()
                .map(|(c, n)| (c.as_str(), *n))
                .collect::<Vec<_>>(),
            vec![
                ("todo", 2),
                ("in progress", 1),
                ("in review", 0),
                ("done", 1),
            ],
            "columns in config order with per-column counts"
        );
        assert_eq!(summary.total, 4);
    }
}