Skip to main content

tix/capacity/
warning.rs

1//! The shared capacity warnings: what a calculation says when something is wrong
2//! (mission `cycle-capacity-planning-01M3C85C`, WP03, T017).
3//!
4//! # One vocabulary, borrowed rather than re-declared
5//!
6//! A capacity finding is about **one issue's estimate** or about a **reference that did
7//! not resolve**, and both vocabularies already exist: the WIP mission's
8//! [`WipDiagnosticKind`] names the estimate problems (`missing_conversion`,
9//! `ambiguous_conversion`, `unknown_unit`, `malformed_estimate`) and WP02's
10//! [`ScopeDiagnosticKind`] names the reference problems (`unknown_scope`,
11//! `ambiguous_scope`). [`CapacityWarningKind`] therefore *carries* those kinds instead of
12//! spelling a second set beside them, so a surface branches on one vocabulary and the
13//! printed spelling stays the registry's own. The one finding this mission adds is the
14//! overflow, which no other mission owns: data-model §5's `capacity_overflow`.
15//!
16//! data-model §5 also lists `invalid_estimate`. That is the WIP mission's
17//! `malformed_estimate` (a value that is not `<number>` or `<number><unit>`) together with
18//! `unknown_unit`/`ambiguous_conversion` (a number that cannot be read in the
19//! calculation's unit) — the spelling stays the registry's, because the registry is where
20//! `tix wip` and `tix lint` already report it, and because a second spelling for one
21//! condition is exactly the drift C-007 forbids.
22//!
23//! # The #1643 shape: a reason and a remedy
24//!
25//! Every warning says what is wrong ([`reason`](CapacityWarning::reason)) and what to do
26//! about it ([`remedy`](CapacityWarning::remedy)) — the shape
27//! [`crate::health::diagnostics::HealthDiagnostic`] uses, so a capacity finding is as
28//! actionable as a health one. [`CapacityWarningKind::default_remedy`] holds the table
29//! once, so every surface (CLI, TUI, MCP, portable) shows the same next step
30//! (C-006/C-007).
31//!
32//! # Warnings never mutate
33//!
34//! A warning is a value: it names the scope, the issues, the unit and the numbers, and
35//! does nothing else. Nothing here writes, moves, resizes or archives anything — this
36//! module holds no reference to a store, a snapshot or a config, so there is no seam
37//! through which a warning could become a mutation (C-006).
38
39use crate::diagnostics::Diagnostic;
40use crate::wip::{WipDiagnostic, WipDiagnosticKind};
41
42use super::calculate::CapacityScope;
43use super::scope::{ScopeDiagnostic, ScopeDiagnosticKind};
44
45/// What one shared capacity finding is about (data-model §5).
46///
47/// The kind is the machine-readable half, so a surface can branch on the condition while
48/// the sentence beside it stays a sentence. Two of the three variants *carry* the
49/// registry that already owns the condition rather than restating it; see the module
50/// docs.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum CapacityWarningKind {
53    /// The scope's committed work exceeds its configured ceiling (C-006).
54    Overflow,
55    /// One issue's estimate could not be counted in the calculation's unit: the WIP
56    /// mission's own estimate vocabulary, verbatim.
57    Conversion(WipDiagnosticKind),
58    /// One reference did not resolve, or resolved twice: WP02's own scope vocabulary,
59    /// verbatim.
60    Scope(ScopeDiagnosticKind),
61}
62
63impl CapacityWarningKind {
64    /// The stable spelling every surface prints and every payload carries.
65    ///
66    /// Delegates to the registry that owns the condition, so `missing_conversion` here
67    /// and `missing_conversion` in `tix wip` are the same string with the same meaning.
68    #[must_use]
69    pub const fn as_str(self) -> &'static str {
70        match self {
71            Self::Overflow => "capacity_overflow",
72            Self::Conversion(kind) => kind.as_str(),
73            Self::Scope(kind) => kind.as_str(),
74        }
75    }
76
77    /// What the reader can do about it — the remedy half of the #1643 shape.
78    ///
79    /// The table lives here so all four surfaces say the same thing (C-007): "committed
80    /// 25d exceeds the capacity of 20d" tells a reader what happened, "raise the ceiling,
81    /// move work, or reduce the scope" tells them what to do. A kind this build has no
82    /// remedy for returns `None`, and the warning then falls back to the registry's own
83    /// sentence when it carried one.
84    #[must_use]
85    pub const fn default_remedy(self) -> Option<&'static str> {
86        match self {
87            Self::Overflow => Some(
88                "reduce the scope's committed work, raise the ceiling in `tix.toml`, or move \
89                 work to a scope with room",
90            ),
91            Self::Conversion(WipDiagnosticKind::MalformedEstimate) => Some(
92                "rewrite `estimate:` as `<number>` or `<number><unit>`, or clear it to leave \
93                 the issue unestimated",
94            ),
95            Self::Conversion(WipDiagnosticKind::MissingConversion) => Some(
96                "declare the unit under `[kanban.<board>.wip.units]`, or state the estimate in \
97                 a unit the board declares",
98            ),
99            Self::Conversion(WipDiagnosticKind::AmbiguousConversion) => {
100                Some("declare a unit the arithmetic divides evenly into, or rewrite the estimate")
101            }
102            Self::Conversion(WipDiagnosticKind::UnknownUnit) => Some(
103                "declare the unit under `[kanban.<board>.wip.units]`, or name one the board \
104                 declares",
105            ),
106            Self::Conversion(_) => None,
107            Self::Scope(ScopeDiagnosticKind::UnknownScope) => {
108                Some("declare the cycle, or remove the reference that names it")
109            }
110            Self::Scope(ScopeDiagnosticKind::AmbiguousScope) => {
111                Some("keep one spelling of the cycle on the issue; the two count once")
112            }
113        }
114    }
115}
116
117/// One shared capacity finding: what it is, what it is about, and what to do (data-model §5).
118///
119/// The numbers are `Option` because a finding is not always about a total: an issue whose
120/// estimate could not be counted has no `current`/`limit`/`overflow`, and the unit it
121/// could not be counted in is still named so the reader knows which conversion to fix.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CapacityWarning {
124    /// What is wrong.
125    pub kind: CapacityWarningKind,
126    /// The scope the finding is about.
127    pub scope: CapacityScope,
128    /// The issues to look at, in issue order. Empty when the finding is about the scope
129    /// itself rather than about work inside it.
130    pub issues: Vec<u32>,
131    /// The committed total the finding compares, when it compares one.
132    pub current: Option<u64>,
133    /// The configured ceiling, when the finding is about a ceiling.
134    pub limit: Option<u64>,
135    /// How far over the ceiling `current` is, when the finding is an overflow.
136    pub overflow: Option<u64>,
137    /// The unit the numbers are counted in, when the finding is about a count.
138    pub unit: Option<String>,
139    /// What is wrong, in one sentence — the reason half of the #1643 shape.
140    pub reason: String,
141    /// What the reader can do about it — the remedy half of the #1643 shape.
142    pub remedy: Option<String>,
143}
144
145impl CapacityWarning {
146    /// A finding about a scope, with its reason. The remedy comes from
147    /// [`CapacityWarningKind::default_remedy`].
148    #[must_use]
149    pub fn new(kind: CapacityWarningKind, scope: CapacityScope, reason: impl Into<String>) -> Self {
150        Self {
151            kind,
152            scope,
153            issues: Vec::new(),
154            current: None,
155            limit: None,
156            overflow: None,
157            unit: None,
158            reason: reason.into(),
159            remedy: kind.default_remedy().map(str::to_string),
160        }
161    }
162
163    /// The scope's committed work is over its ceiling (C-006).
164    ///
165    /// Carries everything the mission says an overflow warning must name: the scope, the
166    /// committed value, the configured capacity, the excess, the unit, and the issues
167    /// whose estimates make up the total.
168    #[must_use]
169    pub fn overflow(
170        scope: CapacityScope,
171        committed: u64,
172        capacity: u64,
173        unit: &str,
174        issues: Vec<u32>,
175    ) -> Self {
176        let over = committed.saturating_sub(capacity);
177        let mut warning = Self::new(
178            CapacityWarningKind::Overflow,
179            scope,
180            format!(
181                "committed {committed}{unit} exceeds the configured capacity of {capacity}{unit} \
182                 by {over}{unit}"
183            ),
184        );
185        warning.issues = issues;
186        warning.current = Some(committed);
187        warning.limit = Some(capacity);
188        warning.overflow = Some(over);
189        warning.unit = Some(unit.to_string());
190        warning
191    }
192
193    /// A finding about one issue's estimate, reusing the WIP mission's kind and sentence.
194    ///
195    /// The estimate value's own diagnostic already names the issue and quotes the raw
196    /// text (`src/capacity/estimate.rs`), so the reason stays the registry's sentence and
197    /// this adds the machine-readable context a surface needs: which issue, which unit.
198    #[must_use]
199    pub fn from_estimate_diagnostic(
200        scope: CapacityScope,
201        issue: u32,
202        unit: &str,
203        finding: &WipDiagnostic,
204    ) -> Self {
205        let kind = CapacityWarningKind::Conversion(finding.kind);
206        let mut warning = Self::new(kind, scope, finding.message.clone())
207            .for_issue(issue)
208            .in_unit(unit);
209        if warning.remedy.is_none() {
210            warning.remedy.clone_from(&finding.because);
211        }
212        warning
213    }
214
215    /// A finding about a reference an issue wrote, reusing WP02's kind and sentence.
216    #[must_use]
217    pub fn from_scope_diagnostic(scope: CapacityScope, finding: &ScopeDiagnostic) -> Self {
218        let kind = CapacityWarningKind::Scope(finding.kind);
219        let mut warning = Self::new(kind, scope, finding.message.clone());
220        if let Some(issue) = finding.issue {
221            warning = warning.for_issue(issue);
222        }
223        if warning.remedy.is_none() {
224            warning.remedy.clone_from(&finding.because);
225        }
226        warning
227    }
228
229    /// The scope's own reference resolves to nothing, so it selects no work.
230    ///
231    /// The remedy is tailored rather than the kind's default because the subject differs:
232    /// the default [`CapacityWarningKind::default_remedy`] for `unknown_scope` speaks to an
233    /// *issue's* reference, while this finding is about the scope the ceiling names — and
234    /// the reference can fail to resolve because no cycle answers to it **or** because a
235    /// stored record's generated identity is half written, which the identity index drops.
236    #[must_use]
237    pub fn unresolved_scope(scope: CapacityScope) -> Self {
238        let reason = format!(
239            "the {kind} `{reference}` does not resolve to a cycle record, so this scope selects \
240             no issues",
241            kind = scope.kind,
242            reference = scope.reference
243        );
244        Self::new(
245            CapacityWarningKind::Scope(ScopeDiagnosticKind::UnknownScope),
246            scope,
247            reason,
248        )
249        .with_remedy(
250            "declare the cycle, complete a record whose generated identity is half written, or \
251             remove the reference that names it",
252        )
253    }
254
255    /// Name one more issue the finding is about.
256    #[must_use]
257    pub fn for_issue(mut self, issue: u32) -> Self {
258        self.issues.push(issue);
259        self
260    }
261
262    /// Name the unit the finding is counted in.
263    #[must_use]
264    pub fn in_unit(mut self, unit: &str) -> Self {
265        self.unit = Some(unit.to_string());
266        self
267    }
268
269    /// Say what to do about it, overriding the kind's default remedy.
270    #[must_use]
271    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
272        self.remedy = Some(remedy.into());
273        self
274    }
275
276    /// Render through the crate's one diagnostic renderer, remedy and all.
277    ///
278    /// The headline is the reason, the notes carry the machine-readable context this
279    /// surface is not the only reader of, and the `help:` footer is the remedy — the
280    /// same layout [`crate::health::diagnostics::HealthDiagnostic::render`] uses.
281    #[must_use]
282    pub fn render(&self) -> String {
283        let mut diagnostic = Diagnostic::warning("E-tix-capacity", self.reason.clone())
284            .with_note(format!("{}: {}", self.kind.as_str(), self.scope.describe()));
285        if let Some(unit) = self.unit.as_deref() {
286            diagnostic = diagnostic.with_note(format!("unit: {unit}"));
287        }
288        if let Some(current) = self.current {
289            diagnostic = diagnostic.with_note(format!("current: {current}"));
290        }
291        if let Some(limit) = self.limit {
292            diagnostic = diagnostic.with_note(format!("limit: {limit}"));
293        }
294        if let Some(overflow) = self.overflow {
295            diagnostic = diagnostic.with_note(format!("overflow: {overflow}"));
296        }
297        if !self.issues.is_empty() {
298            let issues = self
299                .issues
300                .iter()
301                .map(|issue| format!("#{issue}"))
302                .collect::<Vec<_>>()
303                .join(", ");
304            diagnostic = diagnostic.with_note(format!("issues: {issues}"));
305        }
306        if let Some(remedy) = self.remedy.as_deref() {
307            diagnostic = diagnostic.with_help(remedy);
308        }
309        diagnostic.render()
310    }
311}