tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The shared capacity warnings: what a calculation says when something is wrong
//! (mission `cycle-capacity-planning-01M3C85C`, WP03, T017).
//!
//! # One vocabulary, borrowed rather than re-declared
//!
//! A capacity finding is about **one issue's estimate** or about a **reference that did
//! not resolve**, and both vocabularies already exist: the WIP mission's
//! [`WipDiagnosticKind`] names the estimate problems (`missing_conversion`,
//! `ambiguous_conversion`, `unknown_unit`, `malformed_estimate`) and WP02's
//! [`ScopeDiagnosticKind`] names the reference problems (`unknown_scope`,
//! `ambiguous_scope`). [`CapacityWarningKind`] therefore *carries* those kinds instead of
//! spelling a second set beside them, so a surface branches on one vocabulary and the
//! printed spelling stays the registry's own. The one finding this mission adds is the
//! overflow, which no other mission owns: data-model §5's `capacity_overflow`.
//!
//! data-model §5 also lists `invalid_estimate`. That is the WIP mission's
//! `malformed_estimate` (a value that is not `<number>` or `<number><unit>`) together with
//! `unknown_unit`/`ambiguous_conversion` (a number that cannot be read in the
//! calculation's unit) — the spelling stays the registry's, because the registry is where
//! `tix wip` and `tix lint` already report it, and because a second spelling for one
//! condition is exactly the drift C-007 forbids.
//!
//! # The #1643 shape: a reason and a remedy
//!
//! Every warning says what is wrong ([`reason`](CapacityWarning::reason)) and what to do
//! about it ([`remedy`](CapacityWarning::remedy)) — the shape
//! [`crate::health::diagnostics::HealthDiagnostic`] uses, so a capacity finding is as
//! actionable as a health one. [`CapacityWarningKind::default_remedy`] holds the table
//! once, so every surface (CLI, TUI, MCP, portable) shows the same next step
//! (C-006/C-007).
//!
//! # Warnings never mutate
//!
//! A warning is a value: it names the scope, the issues, the unit and the numbers, and
//! does nothing else. Nothing here writes, moves, resizes or archives anything — this
//! module holds no reference to a store, a snapshot or a config, so there is no seam
//! through which a warning could become a mutation (C-006).

use crate::diagnostics::Diagnostic;
use crate::wip::{WipDiagnostic, WipDiagnosticKind};

use super::calculate::CapacityScope;
use super::scope::{ScopeDiagnostic, ScopeDiagnosticKind};

/// What one shared capacity finding is about (data-model §5).
///
/// The kind is the machine-readable half, so a surface can branch on the condition while
/// the sentence beside it stays a sentence. Two of the three variants *carry* the
/// registry that already owns the condition rather than restating it; see the module
/// docs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CapacityWarningKind {
    /// The scope's committed work exceeds its configured ceiling (C-006).
    Overflow,
    /// One issue's estimate could not be counted in the calculation's unit: the WIP
    /// mission's own estimate vocabulary, verbatim.
    Conversion(WipDiagnosticKind),
    /// One reference did not resolve, or resolved twice: WP02's own scope vocabulary,
    /// verbatim.
    Scope(ScopeDiagnosticKind),
}

impl CapacityWarningKind {
    /// The stable spelling every surface prints and every payload carries.
    ///
    /// Delegates to the registry that owns the condition, so `missing_conversion` here
    /// and `missing_conversion` in `tix wip` are the same string with the same meaning.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Overflow => "capacity_overflow",
            Self::Conversion(kind) => kind.as_str(),
            Self::Scope(kind) => kind.as_str(),
        }
    }

    /// What the reader can do about it — the remedy half of the #1643 shape.
    ///
    /// The table lives here so all four surfaces say the same thing (C-007): "committed
    /// 25d exceeds the capacity of 20d" tells a reader what happened, "raise the ceiling,
    /// move work, or reduce the scope" tells them what to do. A kind this build has no
    /// remedy for returns `None`, and the warning then falls back to the registry's own
    /// sentence when it carried one.
    #[must_use]
    pub const fn default_remedy(self) -> Option<&'static str> {
        match self {
            Self::Overflow => Some(
                "reduce the scope's committed work, raise the ceiling in `tix.toml`, or move \
                 work to a scope with room",
            ),
            Self::Conversion(WipDiagnosticKind::MalformedEstimate) => Some(
                "rewrite `estimate:` as `<number>` or `<number><unit>`, or clear it to leave \
                 the issue unestimated",
            ),
            Self::Conversion(WipDiagnosticKind::MissingConversion) => Some(
                "declare the unit under `[kanban.<board>.wip.units]`, or state the estimate in \
                 a unit the board declares",
            ),
            Self::Conversion(WipDiagnosticKind::AmbiguousConversion) => {
                Some("declare a unit the arithmetic divides evenly into, or rewrite the estimate")
            }
            Self::Conversion(WipDiagnosticKind::UnknownUnit) => Some(
                "declare the unit under `[kanban.<board>.wip.units]`, or name one the board \
                 declares",
            ),
            Self::Conversion(_) => None,
            Self::Scope(ScopeDiagnosticKind::UnknownScope) => {
                Some("declare the cycle, or remove the reference that names it")
            }
            Self::Scope(ScopeDiagnosticKind::AmbiguousScope) => {
                Some("keep one spelling of the cycle on the issue; the two count once")
            }
        }
    }
}

/// One shared capacity finding: what it is, what it is about, and what to do (data-model §5).
///
/// The numbers are `Option` because a finding is not always about a total: an issue whose
/// estimate could not be counted has no `current`/`limit`/`overflow`, and the unit it
/// could not be counted in is still named so the reader knows which conversion to fix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapacityWarning {
    /// What is wrong.
    pub kind: CapacityWarningKind,
    /// The scope the finding is about.
    pub scope: CapacityScope,
    /// The issues to look at, in issue order. Empty when the finding is about the scope
    /// itself rather than about work inside it.
    pub issues: Vec<u32>,
    /// The committed total the finding compares, when it compares one.
    pub current: Option<u64>,
    /// The configured ceiling, when the finding is about a ceiling.
    pub limit: Option<u64>,
    /// How far over the ceiling `current` is, when the finding is an overflow.
    pub overflow: Option<u64>,
    /// The unit the numbers are counted in, when the finding is about a count.
    pub unit: Option<String>,
    /// What is wrong, in one sentence — the reason half of the #1643 shape.
    pub reason: String,
    /// What the reader can do about it — the remedy half of the #1643 shape.
    pub remedy: Option<String>,
}

impl CapacityWarning {
    /// A finding about a scope, with its reason. The remedy comes from
    /// [`CapacityWarningKind::default_remedy`].
    #[must_use]
    pub fn new(kind: CapacityWarningKind, scope: CapacityScope, reason: impl Into<String>) -> Self {
        Self {
            kind,
            scope,
            issues: Vec::new(),
            current: None,
            limit: None,
            overflow: None,
            unit: None,
            reason: reason.into(),
            remedy: kind.default_remedy().map(str::to_string),
        }
    }

    /// The scope's committed work is over its ceiling (C-006).
    ///
    /// Carries everything the mission says an overflow warning must name: the scope, the
    /// committed value, the configured capacity, the excess, the unit, and the issues
    /// whose estimates make up the total.
    #[must_use]
    pub fn overflow(
        scope: CapacityScope,
        committed: u64,
        capacity: u64,
        unit: &str,
        issues: Vec<u32>,
    ) -> Self {
        let over = committed.saturating_sub(capacity);
        let mut warning = Self::new(
            CapacityWarningKind::Overflow,
            scope,
            format!(
                "committed {committed}{unit} exceeds the configured capacity of {capacity}{unit} \
                 by {over}{unit}"
            ),
        );
        warning.issues = issues;
        warning.current = Some(committed);
        warning.limit = Some(capacity);
        warning.overflow = Some(over);
        warning.unit = Some(unit.to_string());
        warning
    }

    /// A finding about one issue's estimate, reusing the WIP mission's kind and sentence.
    ///
    /// The estimate value's own diagnostic already names the issue and quotes the raw
    /// text (`src/capacity/estimate.rs`), so the reason stays the registry's sentence and
    /// this adds the machine-readable context a surface needs: which issue, which unit.
    #[must_use]
    pub fn from_estimate_diagnostic(
        scope: CapacityScope,
        issue: u32,
        unit: &str,
        finding: &WipDiagnostic,
    ) -> Self {
        let kind = CapacityWarningKind::Conversion(finding.kind);
        let mut warning = Self::new(kind, scope, finding.message.clone())
            .for_issue(issue)
            .in_unit(unit);
        if warning.remedy.is_none() {
            warning.remedy.clone_from(&finding.because);
        }
        warning
    }

    /// A finding about a reference an issue wrote, reusing WP02's kind and sentence.
    #[must_use]
    pub fn from_scope_diagnostic(scope: CapacityScope, finding: &ScopeDiagnostic) -> Self {
        let kind = CapacityWarningKind::Scope(finding.kind);
        let mut warning = Self::new(kind, scope, finding.message.clone());
        if let Some(issue) = finding.issue {
            warning = warning.for_issue(issue);
        }
        if warning.remedy.is_none() {
            warning.remedy.clone_from(&finding.because);
        }
        warning
    }

    /// The scope's own reference resolves to nothing, so it selects no work.
    ///
    /// The remedy is tailored rather than the kind's default because the subject differs:
    /// the default [`CapacityWarningKind::default_remedy`] for `unknown_scope` speaks to an
    /// *issue's* reference, while this finding is about the scope the ceiling names — and
    /// the reference can fail to resolve because no cycle answers to it **or** because a
    /// stored record's generated identity is half written, which the identity index drops.
    #[must_use]
    pub fn unresolved_scope(scope: CapacityScope) -> Self {
        let reason = format!(
            "the {kind} `{reference}` does not resolve to a cycle record, so this scope selects \
             no issues",
            kind = scope.kind,
            reference = scope.reference
        );
        Self::new(
            CapacityWarningKind::Scope(ScopeDiagnosticKind::UnknownScope),
            scope,
            reason,
        )
        .with_remedy(
            "declare the cycle, complete a record whose generated identity is half written, or \
             remove the reference that names it",
        )
    }

    /// Name one more issue the finding is about.
    #[must_use]
    pub fn for_issue(mut self, issue: u32) -> Self {
        self.issues.push(issue);
        self
    }

    /// Name the unit the finding is counted in.
    #[must_use]
    pub fn in_unit(mut self, unit: &str) -> Self {
        self.unit = Some(unit.to_string());
        self
    }

    /// Say what to do about it, overriding the kind's default remedy.
    #[must_use]
    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
        self.remedy = Some(remedy.into());
        self
    }

    /// Render through the crate's one diagnostic renderer, remedy and all.
    ///
    /// The headline is the reason, the notes carry the machine-readable context this
    /// surface is not the only reader of, and the `help:` footer is the remedy — the
    /// same layout [`crate::health::diagnostics::HealthDiagnostic::render`] uses.
    #[must_use]
    pub fn render(&self) -> String {
        let mut diagnostic = Diagnostic::warning("E-tix-capacity", self.reason.clone())
            .with_note(format!("{}: {}", self.kind.as_str(), self.scope.describe()));
        if let Some(unit) = self.unit.as_deref() {
            diagnostic = diagnostic.with_note(format!("unit: {unit}"));
        }
        if let Some(current) = self.current {
            diagnostic = diagnostic.with_note(format!("current: {current}"));
        }
        if let Some(limit) = self.limit {
            diagnostic = diagnostic.with_note(format!("limit: {limit}"));
        }
        if let Some(overflow) = self.overflow {
            diagnostic = diagnostic.with_note(format!("overflow: {overflow}"));
        }
        if !self.issues.is_empty() {
            let issues = self
                .issues
                .iter()
                .map(|issue| format!("#{issue}"))
                .collect::<Vec<_>>()
                .join(", ");
            diagnostic = diagnostic.with_note(format!("issues: {issues}"));
        }
        if let Some(remedy) = self.remedy.as_deref() {
            diagnostic = diagnostic.with_help(remedy);
        }
        diagnostic.render()
    }
}