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}