tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! One rendering for a decision and for a snapshot (mission
//! `configurable-wip-limits-01M3C6WX`, WP03 T014).
//!
//! C-006 forbids a surface-local bypass, and NFR-003 requires the surfaces to
//! *report* the same thing. A decision that each surface spelled in its own words
//! would satisfy the first rule and break the second: three doors, one rule, three
//! sentences is still three answers. So the sentences live here, and the CLI, the
//! TUI, MCP and `tix lint` all print what these two functions return.

use super::aggregate::WipSnapshot;
use super::policy::WipDecision;

/// The one line a surface shows for a decision.
///
/// It leads with the outcome's stable word, so a script can branch on the first
/// token and a person reads the rest; then the limit it was judged against, then the
/// diagnostics, rendered through the crate's one diagnostic renderer.
///
/// An `allow` carries no diagnostics, so its line is the word and the limit — a
/// caller that wants no line at all for an allow should check the outcome rather
/// than the string.
pub fn render_decision(decision: &WipDecision) -> String {
    let mut out = format!(
        "{}: `{}` -> `{}`",
        decision.as_str(),
        decision.from,
        decision.to
    );
    if let Some(limit) = &decision.limit {
        out.push_str(&format!(" ({})", limit_summary(limit)));
    }
    if decision.diagnostics.is_empty() {
        return out;
    }
    out.push('\n');
    for diagnostic in &decision.diagnostics {
        out.push_str(&diagnostic.render());
        out.push('\n');
    }
    out
}

/// Every violation in `snapshot`, as one block, in board order (T014).
///
/// A column whose estimate total is **unknown** is reported too, and reported
/// first: an unmeasurable total is why a limit cannot be judged at all, and a
/// report that listed only the measurable columns would look complete while
/// silently skipping the one nobody can count.
pub fn render_snapshot(snapshot: &WipSnapshot) -> String {
    let mut out = String::new();
    for diagnostic in snapshot.diagnostics() {
        out.push_str(&diagnostic.render());
        out.push('\n');
    }
    for usage in snapshot.columns() {
        if matches!(usage.state(), super::aggregate::LimitState::Within) {
            continue;
        }
        match usage.state() {
            super::aggregate::LimitState::Over => out.push_str(&format!(
                "{}: `{}` holds {} against {}\n",
                super::config::WipDiagnosticKind::OverLimit.as_str(),
                usage.column,
                usage.cell(),
                usage.limit.as_ref().map_or_else(String::new, limit_summary)
            )),
            super::aggregate::LimitState::Unknown => out.push_str(&format!(
                "{}: `{}` cannot be counted ({}) — a limit cannot be judged\n",
                super::config::WipDiagnosticKind::AmbiguousConversion.as_str(),
                usage.column,
                usage.cell()
            )),
            super::aggregate::LimitState::Within => {}
        }
    }
    out
}

/// A limit as one phrase, shared by both renderings.
fn limit_summary(limit: &super::config::ColumnLimit) -> String {
    let mut parts = Vec::new();
    if let Some(max) = limit.max_items {
        parts.push(format!("max_items {max}"));
    }
    if let Some(max) = limit.max_estimate {
        match limit.unit.as_deref() {
            Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
            None => parts.push(format!("max_estimate {max}")),
        }
    }
    match limit.policy {
        Some(policy) => parts.push(policy.as_str().to_string()),
        None => parts.push("no policy".to_string()),
    }
    parts.join(", ")
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::config::Config;
    use crate::model::Issue;
    use crate::wip::{
        ColumnLimit, LimitPolicy, MoveContext, MoveRequest, WipDiagnostic, WipDiagnosticKind,
        WipOutcome, WipSnapshot,
    };

    fn issue(number: u32, status: &str) -> Issue {
        let contents = format!(
            "---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
             created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
        );
        let mut issue = Issue::parse(&contents).expect("the fixture parses");
        issue.filename = format!("issue-{number}.md");
        issue
    }

    #[test]
    fn wip_diagnostics_render_a_decision_and_its_snapshot() {
        let mut config = Config::default();
        config
            .kanban
            .configurations
            .get_mut("default")
            .expect("the default board")
            .columns = vec![
            "todo".to_string(),
            "in progress".to_string(),
            "done".to_string(),
        ];
        {
            let board = config
                .kanban
                .configurations
                .get_mut("default")
                .expect("the default board");
            board.wip.limits.insert(
                "in progress".to_string(),
                ColumnLimit::items(1, LimitPolicy::Block),
            );
            board.wip.units.insert("d".to_string(), 1);
        }
        let issues = vec![issue(1, "in progress"), issue(2, "in progress")];
        let context = MoveContext::new(&config, &issues);
        let moving = issue(3, "todo");
        // The context carries the issues the board already holds: a decision judged
        // against an empty column would be a decision about a different board.
        let decision = crate::wip::policy::evaluate(
            &MoveContext::new(&config, &issues),
            &MoveRequest::new(&moving, "in progress"),
        );
        assert_eq!(decision.outcome, WipOutcome::Block);
        let rendered = render_decision(&decision);
        assert!(
            rendered.starts_with("block: `todo` -> `in progress`"),
            "{rendered}"
        );
        assert!(rendered.contains("override_required"), "{rendered}");
        assert!(rendered.contains("max_items 1, block"), "{rendered}");

        let snapshot = WipSnapshot::build(config.default_config(), &issues);
        let report = render_snapshot(&snapshot);
        assert!(
            report.contains("over_limit: `in progress` holds 2/1!"),
            "{report}"
        );
        let _ = context;

        // An allow renders as its word alone, which is what a caller checking for a
        // sentence to print can rely on.
        let quiet = crate::wip::policy::evaluate(
            &MoveContext::new(&config, &issues),
            &MoveRequest::new(&issue(4, "todo"), "done"),
        );
        assert_eq!(quiet.outcome, WipOutcome::Allow);
        assert_eq!(render_decision(&quiet), "allow: `todo` -> `done`");
        assert!(
            WipDiagnostic::new(WipDiagnosticKind::OverLimit, "x")
                .render()
                .contains("over_limit")
        );
    }
}