tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix wip`: the CLI adapter over the WIP configuration (mission
//! `configurable-wip-limits-01M3C6WX`, WP04 T019).
//!
//! Read, change, validate, save, print. The **schema's** validation decides whether
//! a configuration is acceptable ([`crate::wip::WipConfig::validate`], which
//! `Config::validate` calls), the **snapshot** decides what the usage is
//! ([`crate::wip::WipSnapshot`]), and this module decides nothing: it does not
//! evaluate a policy, count an issue, or invent a default. That is what keeps a
//! limit's meaning in one place while three surfaces configure and display it.
//!
//! A board with no limits gains no `wip` key: `set` writes only what it was asked
//! for, and the serializer omits everything at its default, so a legacy `tix.toml`
//! that never mentions limits stays byte-identical.

use crate::cli::WipAction;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
use crate::wip::{ColumnLimit, LimitPolicy, WipSnapshot};

/// Run one `tix wip` subcommand.
pub fn run(config: &mut Config, store: &Store, action: &WipAction) -> Result<()> {
    match action {
        WipAction::Show { board } => show(config, store, board.as_deref()),
        WipAction::Set {
            column,
            max_items,
            max_estimate,
            unit,
            policy,
            disabled,
            board,
        } => set(
            config,
            store,
            board.as_deref(),
            column,
            *max_items,
            *max_estimate,
            unit.as_deref(),
            policy.as_deref(),
            *disabled,
        ),
        WipAction::Unset { column, board } => unset(config, store, board.as_deref(), column),
        WipAction::Unit {
            name,
            factor,
            remove,
            board,
        } => unit(config, store, board.as_deref(), name, *factor, *remove),
    }
}

/// The board a subcommand acts on: the named one, or the active board.
fn board_name(config: &Config, requested: Option<&str>) -> String {
    match requested {
        Some(name) => name.to_string(),
        None => crate::workflow::active_board_name(config).to_string(),
    }
}

/// Save `config` after re-validating it, so an edit that would make `tix.toml`
/// unloadable is refused before it is written rather than discovered on the next
/// command.
fn save(config: &mut Config, store: &Store) -> Result<()> {
    config.validate()?;
    let path = crate::commands::views::config_path(store);
    config
        .save(&path)
        .map_err(|error| TixError::Config(format!("could not save `{}`: {error}", path.display())))
}

fn show(config: &Config, store: &Store, requested: Option<&str>) -> Result<()> {
    let name = board_name(config, requested);
    let board = config
        .kanban
        .configurations
        .get(&name)
        .ok_or_else(|| TixError::Config(format!("no board `{name}` in tix.toml")))?;
    let issues = store.list()?;
    let snapshot = WipSnapshot::build(board, &issues);

    println!("board: {name}");
    if board.wip.units.is_empty() {
        println!("units: -");
    } else {
        let units: Vec<String> = board
            .wip
            .units
            .iter()
            .map(|(unit, factor)| format!("{unit}={factor}"))
            .collect();
        println!("units: {}", units.join(", "));
    }
    println!("COLUMN  LIMIT  USAGE");
    for usage in snapshot.columns() {
        let limit = match &usage.limit {
            Some(limit) => describe_limit(limit),
            // A column with no limit is not a column with a limit of zero, and the
            // row says so rather than printing nothing where a limit would go.
            None => "-".to_string(),
        };
        let used = match &usage.limit {
            Some(_) => usage.cell(),
            None => usage.items.to_string(),
        };
        println!("{}  {limit}  {used}", usage.column);
    }
    // The findings go to stderr: `show`'s stdout is the table a script reads, and a
    // diagnostic interleaved into it would be a parse error rather than a warning.
    for diagnostic in snapshot.diagnostics() {
        eprintln!("{}", diagnostic.render());
    }
    Ok(())
}

fn describe_limit(limit: &ColumnLimit) -> String {
    let mut parts: Vec<String> = 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(format!("policy={}", policy.as_str())),
        None => parts.push("policy=-".to_string()),
    }
    if !limit.enabled {
        parts.push("disabled".to_string());
    }
    parts.join(",")
}

/// Set or update one column's limit.
///
/// **Only the flags named change** — the same rule `tix recurring edit` follows —
/// so `set "in progress" --max-items 4` keeps the column's estimate ceiling, its
/// unit and its policy. Creating a limit for the first time therefore needs the
/// fields it is made of: a ceiling and a policy.
#[allow(clippy::too_many_arguments)]
fn set(
    config: &mut Config,
    store: &Store,
    requested: Option<&str>,
    column: &str,
    max_items: Option<u32>,
    max_estimate: Option<u32>,
    unit: Option<&str>,
    policy: Option<&str>,
    disabled: bool,
) -> Result<()> {
    let name = board_name(config, requested);
    let policy = match policy {
        Some(raw) => Some(parse_policy(raw)?),
        None => None,
    };
    let board = config
        .kanban
        .configurations
        .get_mut(&name)
        .ok_or_else(|| TixError::Config(format!("no board `{name}` in tix.toml")))?;
    if !board.columns.iter().any(|configured| configured == column) {
        return Err(TixError::Config(format!(
            "`{column}` is not a column on board `{name}`; valid columns: {}",
            board.columns.join(", ")
        )));
    }
    let limit = board
        .wip
        .limits
        .entry(column.to_string())
        .or_insert_with(ColumnLimit::default);
    if let Some(max) = max_items {
        limit.max_items = Some(max);
    }
    if let Some(max) = max_estimate {
        limit.max_estimate = Some(max);
    }
    if let Some(unit) = unit {
        limit.unit = Some(unit.to_string());
    }
    if let Some(policy) = policy {
        limit.policy = Some(policy);
    }
    // `--disabled` is the only way to say "keep the numbers, enforce nothing", so it
    // is applied as stated; there is no `--enabled` because declaring a limit is
    // what enables it.
    limit.enabled = !disabled;

    save(config, store)?;
    println!("Set the limit for `{column}` on board {name}");
    Ok(())
}

fn unset(config: &mut Config, store: &Store, requested: Option<&str>, column: &str) -> Result<()> {
    let name = board_name(config, requested);
    let board = config
        .kanban
        .configurations
        .get_mut(&name)
        .ok_or_else(|| TixError::Config(format!("no board `{name}` in tix.toml")))?;
    if board.wip.limits.shift_remove(column).is_none() {
        return Err(TixError::Config(format!(
            "board `{name}` has no limit on `{column}`"
        )));
    }
    save(config, store)?;
    println!("Removed the limit for `{column}` on board {name}");
    Ok(())
}

/// Declare or remove one estimate unit.
fn unit(
    config: &mut Config,
    store: &Store,
    requested: Option<&str>,
    name: &str,
    factor: Option<u32>,
    remove: bool,
) -> Result<()> {
    let board_name = board_name(config, requested);
    let board = config
        .kanban
        .configurations
        .get_mut(&board_name)
        .ok_or_else(|| TixError::Config(format!("no board `{board_name}` in tix.toml")))?;
    if remove {
        if board.wip.units.shift_remove(name).is_none() {
            return Err(TixError::Config(format!(
                "board `{board_name}` does not declare the unit `{name}`"
            )));
        }
        save(config, store)?;
        println!("Removed estimate unit `{name}` on board {board_name}");
        return Ok(());
    }
    let factor = factor.ok_or_else(|| {
        TixError::InvalidInput(format!(
            "`tix wip unit {name} <FACTOR>` needs the factor to the board's base unit \
             (or --remove)"
        ))
    })?;
    if factor == 0 {
        return Err(TixError::Config(format!(
            "the factor for `{name}` must be at least 1"
        )));
    }
    board.wip.units.insert(name.to_string(), factor);
    save(config, store)?;
    println!("Estimate unit `{name}` = {factor} on board {board_name}");
    Ok(())
}

/// Parse `--policy`. A well-formed but unknown word is a refusal, not a panic, and
/// the accepted spellings are named so a caller can fix the typo.
fn parse_policy(raw: &str) -> Result<LimitPolicy> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "warn" => Ok(LimitPolicy::Warn),
        "block" => Ok(LimitPolicy::Block),
        other => Err(TixError::InvalidInput(format!(
            "unknown policy `{other}`; `warn` reports the violation and proceeds, \
             `block` refuses the move unless it is overridden"
        ))),
    }
}