tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix workflow categories|show|transitions` — the inspection surface for the
//! category vocabulary, a board's resolved mapping, and the effective transition
//! policy (FR-001, `contracts/cli-surface.md` §1).
//!
//! ## Why this exists
//!
//! The vocabulary and the mapping are configuration a user cannot otherwise see.
//! `tix.toml` says `category_map = { shipped = "completed" }`; nothing tells the
//! user what `completed` means, which columns fell back to the safe default, or
//! whether the board is subject to the terminal lock at all. Every one of those is
//! a policy that silently governs their work.
//!
//! `tix workflow show` therefore reports **the source of each resolution**, not
//! just the result. A column reading `unstarted` because the board mapped it, and a
//! column reading `unstarted` because nothing was configured, are the same answer
//! to "what category is this?" and very different answers to "is my config doing
//! what I meant?" — the documented safe default is meant to be *observable*
//! (`category-vocabulary.md`).
//!
//! ## Never writes, and never errors on a sparse board
//!
//! Every subcommand is a pure read. A board with no `category_map` is not an error
//! — it is the configuration every repository in the world has today, and erroring
//! on it would make the command useless for exactly the users most likely to run
//! it. It reports the vocabulary and says which columns relied on the default
//! (NFR-003).

use crate::config::Config;
use crate::error::Result;
use crate::workflow::{DiagnosticKind, WorkflowCategory, active_board_name, validate_config};

/// `tix workflow categories`: the global vocabulary, in order.
pub fn categories(config: &Config) -> Result<()> {
    let row = |left: &str, middle: &str, right: &str| {
        println!("{left:<18}  {middle:<10}  {right}");
    };
    row("KEY", "LABEL", "TERMINAL");
    for key in &config.categories.order {
        // A configured order is validated against the vocabulary at load, so every
        // key here is in it. `from_key` cannot fail; defaulting instead of
        // `expect`ing keeps a future vocabulary entry from turning an inspection
        // command into a panic.
        let Some(category) = WorkflowCategory::from_key(key) else {
            continue;
        };
        row(
            category.key(),
            category.label(),
            if category.is_terminal() { "yes" } else { "" },
        );
    }
    println!();
    println!("Order: {}", config.categories.order.join(" -> "));
    println!("Safe default: {}", config.categories.default);
    println!(
        "Complete: {}",
        WorkflowCategory::from_key(crate::config::COMPLETE_CATEGORY)
            .map(|c| c.label())
            .unwrap_or(crate::config::COMPLETE_CATEGORY)
    );
    if config.categories.order.len() == crate::config::CATEGORY_KEYS.len() {
        println!("(the built-in vocabulary, unchanged)");
    } else {
        println!(
            "(custom order; the built-in vocabulary has {} keys)",
            crate::config::CATEGORY_KEYS.len()
        );
    }
    Ok(())
}

/// `tix workflow show <board>`: the board's columns, their categories, and where
/// each resolution came from.
pub fn show(config: &Config, board_name: Option<&str>) -> Result<()> {
    // An explicitly named board that does not exist is a real user error — they
    // typed a name and got silence otherwise. Omitting the name is not: that means
    // "the active one", which `active_board_name` resolves.
    let name = match board_name {
        Some(requested) => match config.kanban.configurations.get(requested) {
            Some(_) => requested,
            None => {
                let known: Vec<&str> = config
                    .kanban
                    .configurations
                    .keys()
                    .map(String::as_str)
                    .collect();
                return Err(crate::error::TixError::NotFound(format!(
                    "board `{requested}`; configured boards: {}",
                    if known.is_empty() {
                        "(none)".to_string()
                    } else {
                        known.join(", ")
                    }
                )));
            }
        },
        None => active_board_name(config),
    };
    let board =
        config.kanban.configurations.get(name).expect(
            "the name came from this same map, or `active_board_name` returned a key from it",
        );

    let resolution = crate::workflow::resolution(config, board, name);
    let is_active = name == active_board_name(config);

    println!("Board: {name}{}", if is_active { " (active)" } else { "" });
    println!(
        "Completion column: {}",
        board
            .effective_done_column()
            .map(|c| c.to_string())
            .unwrap_or_else(|| "(none configured)".to_string())
    );
    // The opt-in that decides whether this board has transition policy at all
    // (WP03). Reported here because it is invisible in `tix.toml` in the other
    // direction: a user who declared *some* mappings may not have realised they
    // thereby opted into the terminal lock.
    println!(
        "Category policy: {}",
        if resolution.explicit_policy {
            "declared (`category_map` present) — terminal lock and adjacency apply"
        } else {
            "not declared (no `category_map`) — completion only, no transition rules"
        }
    );
    println!();
    // `TERMINAL` is the last column, so it is a trailing literal rather than a
    // format argument — clippy's `print_literal` is right that `{}` with no
    // placeholder is a format string in disguise.
    println!(
        "{:<20}  {:<12}  {:<14}  TERMINAL",
        "COLUMN", "CATEGORY", "SOURCE"
    );
    for column in board.columns.iter() {
        let category = resolution.resolve(column);
        // "Explicit or safe default" is the whole point of this table. Both answer
        // "what category"; only one answers "did my config do that on purpose".
        let source = if board.category_map.contains_key(column) {
            "category_map"
        } else {
            "safe default"
        };
        println!(
            "{:<20}  {:<12}  {:<14}  {}",
            column,
            category.map(|c| c.key()).unwrap_or("(unmapped)"),
            source,
            category.is_some_and(|c| c.is_terminal())
        );
    }

    // The board's own diagnostics, by their contract names. `validate_config` walks
    // every board, so the ones belonging to other boards are filtered out rather
    // than reported here — a user asking about `staging` should not read findings
    // about `default`.
    let findings: Vec<_> = validate_config(config)
        .into_iter()
        .filter(|d| d.subject.starts_with(name) || d.subject == name)
        .collect();
    if !findings.is_empty() {
        println!();
        println!("Diagnostics:");
        for finding in &findings {
            println!("  {}: {}", finding.kind, finding.message);
        }
    }

    // The unmapped columns, called out separately because a safe default that is
    // invisible is the failure `unmapped_column_default` exists to report. A board
    // that resolved everything explicitly says nothing here rather than "none".
    if !resolution.unmapped.is_empty() {
        println!();
        // Only the columns the user would want to *change* are listed. The board's
        // `done_column` is also resolved by the default on a board with no map, but
        // that is the legacy reconciliation working as intended rather than a
        // surprise, so naming it here would read as a defect. It is already shown
        // as `TERMINAL` in the table above and named as the completion column.
        let settable: Vec<&String> = resolution
            .unmapped
            .iter()
            .filter(|c| {
                !resolution
                    .resolve(c)
                    .is_some_and(WorkflowCategory::is_complete)
            })
            .collect();
        if settable.is_empty() {
            println!(
                "Every column resolves through the board's completion column; there is \\
                 nothing left to map."
            );
        } else {
            println!(
                "Resolved by the safe default ({} column(s)): {}",
                settable.len(),
                settable
                    .iter()
                    .map(|s| s.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            );
            println!("  → to map them explicitly, add a [kanban.{name}.category_map] table.");
        }
        println!(
            "  → a board with no column resolving to `completed` cannot record a \
             completed move: {}.",
            DiagnosticKind::MissingCompletionCategory
        );
    }
    Ok(())
}

/// `tix workflow transitions`: the effective policy, in words.
pub fn transitions(config: &Config) -> Result<()> {
    let (_, resolution) = crate::workflow::active_resolution(config);
    let t = &config.transitions;
    println!("Effective transition policy for board {}", resolution.board);
    println!();
    println!(
        "Terminal lock: {}",
        if t.terminal_locked { "on" } else { "off" }
    );
    println!(
        "  Applies: {}",
        if t.terminal_locked && resolution.explicit_policy {
            "yes — this board declares a `category_map`, so an issue in a terminal \
             category cannot leave it without a reasoned override"
        } else if t.terminal_locked {
            "no — `terminal_locked` is on, but this board declares no `category_map`, \
             so there is no terminal policy to apply"
        } else {
            "no — `terminal_locked` is off in `tix.toml`"
        }
    );
    println!();
    println!(
        "Category adjacency: {}",
        if resolution.explicit_policy {
            "on"
        } else {
            "off"
        }
    );
    println!("  A category may move to itself or forward through the declared order.");
    println!("  Order: {}", config.categories.order.join(" -> "));
    println!(
        "  A move backwards is refused as `{}`.",
        DiagnosticKind::InvalidTransition
    );
    println!();
    println!("Escape hatch: a reasoned override");
    println!("  tix mv <issue> <column> --reason \"why\"");
    println!("  tix edit <issue> --status <column> --reason \"why\"");
    println!(
        "  A blank reason is not a reason and is refused. The reason is recorded in \
         the issue's History as a `transition_accepted` decision."
    );
    println!();
    println!("Refusals write nothing: no issue file, no tix.toml, no History entry.");
    Ok(())
}