tix-tracker 0.6.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::model::Issue;
use crate::planning::Planner;
use crate::search::control_token;
use crate::storage::Store;
use std::collections::BTreeMap;

/// 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,
    filter: Option<&str>,
    sort: Option<&str>,
    group: Option<&str>,
) -> Result<()> {
    let mut issues = store.list()?;
    if let Some(filter) = filter {
        // `parent:` is a selector and `category:` is derived; neither can be matched
        // from the file's bytes. Both are resolved here into the pair
        // `matches_issue` needs (WP08 T035). Going through `query_matches` would
        // report **unfiltered** counts for a filter the user believes is applied —
        // a summary that looks like an answer.
        //
        // The same two lines appear in `commands::list::query` and
        // `commands::search::find`. They are repeated rather than shared because
        // each takes its filter from a different argument and `this` module's
        // `sort_key`/`group_key` below still read the **outer** `filter` for
        // `sort:`/`group:`; a helper spanning four call-site-specific shapes is a
        // helper nobody reads. The inner names are suffixed so shadowing cannot make
        // the `control_token` calls below silently start reading a rewritten query.
        let (resolved, criteria) = crate::search::resolve_filter_criteria(store, filter)?;
        let (_, resolution) = crate::workflow::active_resolution(config);
        issues.retain(|i| {
            crate::search::matches_issue(
                &resolved,
                &criteria,
                i,
                &i.to_file_contents(),
                &resolution,
            )
        });
    }
    let sort_key = sort
        .map(str::to_string)
        .or_else(|| filter.and_then(|q| control_token(q, "sort")));
    sort_issues(&mut issues, sort_key.as_deref());
    let summary = board_for_issues(config, &issues);
    let group_key = group
        .map(str::to_string)
        .or_else(|| filter.and_then(|q| control_token(q, "group")));
    let show_controls = filter.is_some() || sort_key.is_some() || group_key.is_some();
    print!(
        "{}",
        render(
            config,
            store,
            &issues,
            &summary,
            show_controls,
            group_key.as_deref(),
        )
    );
    Ok(())
}

/// Render the board summary, as text.
///
/// WP04: **one** renderer for both surfaces. `tix status` and MCP's `board_status`
/// are required to be byte-identical (NFR-005, `surface-parity.md`), and until this
/// existed they were two hand-maintained copies of the same table — each with its own
/// `Overdue` calculation, its own group rollup, and its own idea of which column
/// means done. The parity test caught the divergence the moment the CLI's label
/// changed to `complete` and MCP's did not, which is exactly what a duplicated
/// renderer invites.
///
/// The parameters are the *decisions* rather than the raw flags, so a caller cannot
/// present a combination the CLI would not: `show_controls` and `group_key` are
/// already resolved from `filter`/`sort`/`group` by [`run`].
pub fn render(
    config: &Config,
    store: &Store,
    issues: &[Issue],
    summary: &BoardSummary,
    show_controls: bool,
    group_key: Option<&str>,
) -> String {
    let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
    // WP04 T019: completion through the service (C-004). This used to compare against
    // `effective_done_column().unwrap_or("done")` — a column *name* resolved in the
    // command, with a literal `"done"` as the fallback.
    //
    // The fallback was the defect, not a convenience. A board that configures no
    // `done_column` resolved to the literal string `done`, so an issue in a column
    // actually named `done` counted as complete on a board that had declared nothing,
    // while the same board's `tix mv` would not have recorded that move as reaching
    // completion. `is_completed` never errors: an issue in a column the board does not
    // configure is simply not complete, which is the honest answer for a board that
    // declared no completion column.
    let (_, resolution) = crate::workflow::active_resolution(config);

    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 mut out = String::new();
    let mut row = |column: String, count: String| {
        out.push_str(&format!("{column:<column_width$}  {count:<count_width$}\n"));
    };

    row("COLUMN".into(), "COUNT".into());
    for (column, count) in &summary.columns {
        row(column.clone(), count.to_string());
    }
    row("Total".into(), summary.total.to_string());
    if show_controls {
        let today = crate::model::today();
        let overdue = issues
            .iter()
            .filter(|i| {
                i.due_date
                    .as_deref()
                    .is_some_and(|due| due < today.as_str())
                    && !crate::workflow::is_completed(i, &resolution)
            })
            .count();
        row("Overdue".into(), overdue.to_string());
    }
    if let Some(group_key) = group_key.filter(|g| *g == "cycle" || *g == "milestone") {
        let mut groups: BTreeMap<String, (usize, usize)> = BTreeMap::new();
        for issue in issues {
            let group = match group_key {
                "cycle" => issue
                    .cycles
                    .as_ref()
                    .and_then(|v| v.first().cloned())
                    .unwrap_or_else(|| "(none)".to_string()),
                "milestone" => issue
                    .milestones
                    .as_ref()
                    .and_then(|v| v.first().cloned())
                    .unwrap_or_else(|| "(none)".to_string()),
                _ => "(none)".to_string(),
            };
            let entry = groups.entry(group).or_insert((0, 0));
            entry.1 += 1;
            // The service's completion decision, for the same reason as the `Overdue`
            // row: a group header must mean the same thing as a `--progress` figure
            // and as `tix list`'s rollup.
            if crate::workflow::is_completed(issue, &resolution) {
                entry.0 += 1;
            }
        }
        for (group, (done, total)) in groups {
            let pct = if total == 0 { 0 } else { (done * 100) / total };
            // WP04: the word is `complete`, not `done`. The old label named a column,
            // and a board whose completion column is `shipped` was reporting `3/4 done`
            // — describing a column the board does not have.
            out.push_str(&format!(
                "{group_key} {group}: {done}/{total} complete ({pct}%)\n"
            ));
        }
    }
    // The `show_progress` gate lives here rather than in the caller, so the two
    // surfaces cannot disagree about when the section appears.
    let show_progress = issues.iter().any(|i| {
        i.cycles.as_ref().is_some_and(|v| !v.is_empty())
            || i.milestones.as_ref().is_some_and(|v| !v.is_empty())
    });
    if show_progress {
        for cycle in planner.cycle_names() {
            let progress = planner.cycle_progress(store, &cycle);
            if progress.total > 0 {
                // `complete`, not `done`: the figure already comes from the service
                // (`Planner::is_done_issue` delegates to `workflow::is_completed`), so
                // the label was the last thing still naming a column.
                out.push_str(&format!(
                    "cycle {cycle}: {}/{} complete\n",
                    progress.done, progress.total
                ));
            }
        }
        for milestone in planner.milestone_names().unwrap_or_default() {
            let progress = planner.milestone_progress(store, &milestone);
            if progress.total > 0 {
                out.push_str(&format!(
                    "milestone {milestone}: {}/{} complete\n",
                    progress.done, progress.total
                ));
            }
        }
    }
    out
}

/// 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()?;
    Ok(board_for_issues(config, &issues))
}

/// The summary for a given issue set.
///
/// WP04: `pub` because MCP's `board_status` used to keep its own copy of this
/// counting, alongside its own copy of the *rendering*. Two copies of the counting
/// and two of the rendering is four places for one table to drift; the parity test
/// caught the rendering pair the moment the CLI's label changed.
pub fn board_for_issues(config: &Config, issues: &[Issue]) -> BoardSummary {
    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();
    BoardSummary { columns, total }
}

fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
    match sort.unwrap_or("created") {
        "due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
        "start" => issues.sort_by(|a, b| {
            a.start_date
                .cmp(&b.start_date)
                .then(a.number.cmp(&b.number))
        }),
        "reviewer" => {
            issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
        }
        "cycle" => issues.sort_by(|a, b| {
            a.cycles
                .as_ref()
                .and_then(|v| v.first())
                .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
                .then(a.number.cmp(&b.number))
        }),
        "milestone" => issues.sort_by(|a, b| {
            a.milestones
                .as_ref()
                .and_then(|v| v.first())
                .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
                .then(a.number.cmp(&b.number))
        }),
        "status" => issues.sort_by(|a, b| {
            a.status
                .display()
                .cmp(b.status.display())
                .then(a.number.cmp(&b.number))
        }),
        "priority" => issues.sort_by(|a, b| {
            a.priority
                .to_string()
                .cmp(&b.priority.to_string())
                .then(a.number.cmp(&b.number))
        }),
        _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
    }
}

#[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,
            relationships: vec![],
            related_issues: vec![],
            files: vec![],
            cycles: None,
            milestones: None,
            reviewer: None,
            start_date: None,
            due_date: None,
            created: "2026-01-01".to_string(),
            type_: None,
            updated: "2026-01-01".to_string(),
            filename: String::new(),
            body: "body".to_string(),
            history: crate::model::History::new(),
            has_retired_links_key: false,
            parent: None,
            extra_frontmatter: std::collections::BTreeMap::new(),
            archived: false,
            entered_at: None,
            source: None,
        };
        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);
    }
}