tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! WIP evaluation for a batch (mission `configurable-wip-limits-01M3C6WX`, WP07
//! T034/T035).
//!
//! # Projected, not aggregate
//!
//! A batch moving four issues into a column capped at three is not one decision
//! about four issues and it is not four decisions about the same state: the first
//! two are within the limit, the third reaches it, and the fourth is over — because
//! each item moves into the column the items before it have already entered. So the
//! evaluation runs **in plan order over a projection**: the issue set with the
//! moves that have already been decided applied to it, in memory.
//!
//! That is also why this is a separate function rather than a loop at the call site:
//! the plan preview and the apply must both see the same projection, or a preview
//! that says "the fourth item is blocked" can be followed by an apply that blocks
//! the second.
//!
//! # What it does not do
//!
//! It writes nothing, decides nothing about batches that are not status moves, and
//! adds no vocabulary: every item carries a [`WipDecision`] from the one policy
//! service, and the caller maps that onto whatever outcome type it already has
//! (the bulk report's `applied`/`no-op`/`rejected`/`failed`).

use std::collections::BTreeMap;

use crate::config::Config;
use crate::model::Issue;

use super::r#override::OverrideRequest;
use super::policy::{MoveContext, MoveRequest, WipDecision, evaluate};

/// One item's decision, in plan order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemWip {
    /// The issue that would move.
    pub number: u32,
    /// The column it would move to.
    pub to: String,
    /// The shared policy's decision.
    pub decision: WipDecision,
}

/// Annotate a batch plan with its items' WIP decisions (WP07 T035).
///
/// **The caller supplies the issue set, and it must be the store's, not the
/// selection's.** A column's usage counts every card in it, including the ones the
/// batch does not touch: evaluating a one-item batch against its own selection saw
/// an empty target column and reported `allow` for a move the apply then refused.
/// This function therefore takes the issues explicitly rather than reading them from
/// the plan, which only ever holds what was selected.
pub fn annotate_plan(plan: &mut crate::bulk::plan::BatchPlan, config: &Config, issues: &[Issue]) {
    let moves: Vec<(u32, String)> = plan
        .items
        .iter()
        .filter_map(|item| match &plan.patch.status {
            crate::bulk::patch::PatchValue::Set(target) => Some((item.number, target.clone())),
            _ => None,
        })
        .collect();
    let mut decisions: BTreeMap<u32, ItemWip> =
        evaluate_batch(config, issues, &moves, &BTreeMap::new())
            .into_iter()
            .map(|item| (item.number, item))
            .collect();
    for item in &mut plan.items {
        item.wip = decisions.remove(&item.number);
    }
}

impl ItemWip {
    /// Whether the item may be written.
    pub fn is_committable(&self) -> bool {
        self.decision.is_committable()
    }
}

/// Judge every status move in a batch, in order, against the projected state
/// (T034/T035).
///
/// `moves` is the batch's status changes in plan order — `(issue number, target
/// column)` — and `overrides` carries the explicit, confirmed override for an item
/// that has one (a caller that wants a single override for the whole batch passes
/// the same request for each item, which is what the bulk CLI's `--override` does;
/// the policy then judges each one against the limit *that item* sees).
///
/// Items whose issue is not in `issues` are skipped: the plan's own validation
/// refuses an unknown issue, and inventing a decision for one would be inventing
/// state.
pub fn evaluate_batch(
    config: &Config,
    issues: &[Issue],
    moves: &[(u32, String)],
    overrides: &BTreeMap<u32, OverrideRequest>,
) -> Vec<ItemWip> {
    // A board with no limits has no WIP question to ask, and asking it per item is
    // the difference between a plan over ten thousand items being free and being
    // quadratic: `evaluate` builds a snapshot of the whole issue set, so a batch must
    // not call it once per item when every answer would be `allow`. The plan's
    // `wip` field is `Option` for exactly this reason.
    if !config.default_config().wip.has_enabled_limits() {
        return Vec::new();
    }
    // The projection: the caller's snapshot, with each decided move applied as we go.
    let mut projected: Vec<Issue> = issues.to_vec();
    let mut out = Vec::with_capacity(moves.len());
    for (number, target) in moves {
        let Some(index) = projected.iter().position(|issue| issue.number == *number) else {
            continue;
        };
        let context = MoveContext::new(config, &projected);
        let request = MoveRequest::new(&projected[index], target);
        let decision = match overrides.get(number) {
            Some(over) => evaluate(&context, &request.overriding(over)),
            None => evaluate(&context, &request),
        };
        out.push(ItemWip {
            number: *number,
            to: target.clone(),
            decision,
        });
        // The move is projected whether or not it would be committed: the *plan* is
        // what the items would do, and a preview that stopped projecting at the
        // first refusal would report every later item against a state the user is
        // not going to be in.
        projected[index].status = crate::model::Status::new(target);
    }
    // `request` is only mutated by `because`, which a batch does not use; the binding
    // exists so the borrow of `projected[index]` ends before the projection is
    // updated.
    let _ = &mut out;
    out
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::wip::{ColumnLimit, LimitPolicy, WipConfig};

    fn config(limit: u32, policy: LimitPolicy) -> Config {
        let mut config = Config::default();
        let board = config
            .kanban
            .configurations
            .get_mut("default")
            .expect("the default board");
        board.columns = ["todo", "in progress", "done"]
            .into_iter()
            .map(String::from)
            .collect();
        board.done_column = Some("done".to_string());
        board.wip = WipConfig {
            units: [("d".to_string(), 1u32)].into_iter().collect(),
            limits: [("in progress".to_string(), ColumnLimit::items(limit, policy))]
                .into_iter()
                .collect(),
        };
        config
    }

    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_bulk_projects_each_item_in_order() {
        let config = config(3, LimitPolicy::Block);
        let issues = vec![
            issue(1, "todo"),
            issue(2, "todo"),
            issue(3, "todo"),
            issue(4, "todo"),
        ];
        let moves: Vec<(u32, String)> = (1..=4)
            .map(|number| (number, "in progress".to_string()))
            .collect();
        let decisions = evaluate_batch(&config, &issues, &moves, &BTreeMap::new());
        assert_eq!(decisions.len(), 4);
        // Three fit; the fourth is over — and that is a fact about the projection,
        // not about the batch as a whole.
        let kinds: Vec<&str> = decisions
            .iter()
            .map(|item| item.decision.as_str())
            .collect();
        assert_eq!(kinds, ["allow", "allow", "allow", "block"]);
        assert!(decisions[0].is_committable());
        assert!(!decisions[3].is_committable());
        assert_eq!(decisions[3].decision.state_after.as_str(), "over");
    }

    #[test]
    fn wip_bulk_an_override_carries_only_the_item_that_has_one() {
        let config = config(1, LimitPolicy::Block);
        let issues = vec![issue(1, "in progress"), issue(2, "todo"), issue(3, "todo")];
        let moves: Vec<(u32, String)> = (2..=3)
            .map(|number| (number, "in progress".to_string()))
            .collect();
        let limit = config
            .default_config()
            .wip
            .effective_limit("in progress")
            .expect("the limit")
            .clone();
        let over = OverrideRequest::new(
            "alice",
            "2026-01-02T03:04:05Z",
            "release week",
            super::super::r#override::BoundLimit::of("in progress", &limit),
        );
        let overrides = BTreeMap::from([(2u32, over)]);
        let decisions = evaluate_batch(&config, &issues, &moves, &overrides);
        assert_eq!(decisions[0].decision.as_str(), "override");
        assert!(decisions[0].decision.history.is_some(), "one event");
        // The item after it is over the limit *because* the override moved 2 in, and
        // it has no override of its own.
        assert_eq!(decisions[1].decision.as_str(), "block");
        assert!(decisions[1].decision.history.is_none());
    }

    #[test]
    fn wip_bulk_an_unknown_issue_is_skipped_rather_than_invented() {
        let config = config(3, LimitPolicy::Warn);
        let issues = vec![issue(1, "todo")];
        let moves = vec![
            (1u32, "in progress".to_string()),
            (99u32, "in progress".to_string()),
        ];
        let decisions = evaluate_batch(&config, &issues, &moves, &BTreeMap::new());
        assert_eq!(decisions.len(), 1);
        assert_eq!(decisions[0].number, 1);
    }
}