tix/analytics/diagnostics.rs
1//! The shared analytics findings (mission `burndown-scope-analytics-01M3C9J1`,
2//! WP02 T010).
3//!
4//! # One vocabulary, four surfaces
5//!
6//! `data-model.md` §7 fixes the closed six-name vocabulary — `missing_history`,
7//! `unclassified_event`, `missing_conversion`, `invalid_period`,
8//! `closed_snapshot`, `projection_unknown` — and this module is its one home. CLI,
9//! TUI, MCP and the local chart document all render the **same value** through
10//! [`AnalyticsDiagnostic::render`], so no surface can invent a seventh name or
11//! re-spell an existing one (C-008); a surface that needs to branch reads
12//! [`AnalyticsDiagnosticKind`], never the sentence beside it.
13//!
14//! # The reason/remedy shape
15//!
16//! The shape is the crate's #1643 one, the same pair
17//! [`crate::capacity::warning::CapacityWarning`] and
18//! [`crate::health::diagnostics::HealthDiagnostic`] carry: a machine-readable
19//! [`kind`](AnalyticsDiagnosticKind), the stable scope id the finding is about, an
20//! optional `date` and `event_id` locating it, a `message` that says what happened,
21//! and an optional `remedy` table entry from
22//! [`AnalyticsDiagnosticKind::default_remedy`] that says what to do about it. The
23//! remedy table lives on the kind, not at each construction site, so two call
24//! sites cannot explain one condition two ways.
25//!
26//! # Nothing here invents a value
27//!
28//! A diagnostic never carries a number the series did not derive. It names what is
29//! missing (`missing_history`), what could not be placed (`unclassified_event`),
30//! what the period excluded (`closed_snapshot`) or why a series could not be
31//! enumerated at all (`invalid_period`) — and it is the *only* thing the fold adds
32//! when it cannot answer: the point beside it stays `unknown`/`coverage_limited`
33//! rather than becoming a zero (C-005).
34//!
35//! # Ordering
36//!
37//! Diagnostics are `Ord` over `(kind, scope, date, event_id, message, remedy)`, and
38//! [`crate::analytics::series`] sorts them before a series leaves the service. Two
39//! runs over one snapshot therefore produce byte-identical diagnostics, which is
40//! C-007's determinism applied to the diagnostic half of the payload rather than
41//! only to the points.
42
43use std::fmt;
44
45use crate::diagnostics::Diagnostic;
46
47/// What one analytics finding is about (`data-model.md` §7).
48///
49/// The set is closed at the contract's six names. Three of them are produced by the
50/// count-mode fold ([`MissingHistory`](Self::MissingHistory),
51/// [`UnclassifiedEvent`](Self::UnclassifiedEvent),
52/// [`InvalidPeriod`](Self::InvalidPeriod) and
53/// [`ClosedSnapshot`](Self::ClosedSnapshot)); the other two are declared here so
54/// WP03 fills its half of one vocabulary rather than starting a second
55/// ([`MissingConversion`](Self::MissingConversion),
56/// [`ProjectionUnknown`](Self::ProjectionUnknown)).
57#[derive(
58 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
59)]
60#[serde(rename_all = "snake_case")]
61pub enum AnalyticsDiagnosticKind {
62 /// The period holds a day (or an event) with no recorded history, so a value
63 /// beside it is coverage-limited rather than a zero (C-005).
64 MissingHistory,
65 /// A recorded event carries a class the fold cannot place, so it moves no
66 /// line and is reported instead of dropped.
67 UnclassifiedEvent,
68 /// A value needs an explicit unit conversion the shared service does not
69 /// supply. WP03 owns the resolution path; the name is fixed here (C-003).
70 MissingConversion,
71 /// A period could not be enumerated: a bound is absent with nothing observed
72 /// to stand in for it, or the span is too long to be a series.
73 InvalidPeriod,
74 /// The scope is bound to a closed snapshot: its period and provenance are the
75 /// snapshot's, and events after its close are not contributions (C-006).
76 ClosedSnapshot,
77 /// A projection could not be drawn from the recorded history. WP03 owns the
78 /// forecast; the name is fixed here (C-004).
79 ProjectionUnknown,
80}
81
82impl AnalyticsDiagnosticKind {
83 /// Every kind, in the vocabulary's own order.
84 pub const ALL: [AnalyticsDiagnosticKind; 6] = [
85 AnalyticsDiagnosticKind::MissingHistory,
86 AnalyticsDiagnosticKind::UnclassifiedEvent,
87 AnalyticsDiagnosticKind::MissingConversion,
88 AnalyticsDiagnosticKind::InvalidPeriod,
89 AnalyticsDiagnosticKind::ClosedSnapshot,
90 AnalyticsDiagnosticKind::ProjectionUnknown,
91 ];
92
93 /// The stable spelling every surface prints and every payload carries.
94 ///
95 /// These are the contract's own names (`data-model.md` §7), so a payload
96 /// parser in another language matches on them without a translation table.
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 AnalyticsDiagnosticKind::MissingHistory => "missing_history",
100 AnalyticsDiagnosticKind::UnclassifiedEvent => "unclassified_event",
101 AnalyticsDiagnosticKind::MissingConversion => "missing_conversion",
102 AnalyticsDiagnosticKind::InvalidPeriod => "invalid_period",
103 AnalyticsDiagnosticKind::ClosedSnapshot => "closed_snapshot",
104 AnalyticsDiagnosticKind::ProjectionUnknown => "projection_unknown",
105 }
106 }
107
108 /// Parse a canonical spelling; `None` for anything outside the vocabulary.
109 pub fn parse(raw: &str) -> Option<Self> {
110 Self::ALL
111 .into_iter()
112 .find(|kind| kind.as_str() == raw.trim())
113 }
114
115 /// What the reader can do about it — the remedy half of the #1643 shape.
116 ///
117 /// A kind this build has no remedy for returns `None`, and the diagnostic then
118 /// carries only its reason. The table is *on the kind* so every construction
119 /// site offers the same advice.
120 pub const fn default_remedy(self) -> Option<&'static str> {
121 match self {
122 AnalyticsDiagnosticKind::MissingHistory => Some(
123 "widen the period with an earlier `--from` so the recorded events that predate \
124 it are included, or read the coverage marker beside the value",
125 ),
126 AnalyticsDiagnosticKind::UnclassifiedEvent => None,
127 AnalyticsDiagnosticKind::MissingConversion => Some(
128 "choose `--mode count`, or declare the unit under `[kanban.<board>.wip.units]` \
129 and state the estimate in it",
130 ),
131 AnalyticsDiagnosticKind::InvalidPeriod => {
132 Some("pass a bounded period, or record at least one dated event to bound it")
133 }
134 AnalyticsDiagnosticKind::ClosedSnapshot => None,
135 AnalyticsDiagnosticKind::ProjectionUnknown => Some(
136 "record at least one completion inside the period before asking for a forecast",
137 ),
138 }
139 }
140}
141
142impl fmt::Display for AnalyticsDiagnosticKind {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.write_str(self.as_str())
145 }
146}
147
148/// One analytics finding, with everything a surface needs to explain it
149/// (`data-model.md` §7).
150///
151/// `scope` is the **stable** scope id, not the display string: the finding has to
152/// survive the scope's `Display` changing, and a payload consumer keys on the id.
153/// `date` and `event_id` are `Option` because a finding is not always about one
154/// day or one event — an invalid period is about the whole series.
155#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
156pub struct AnalyticsDiagnostic {
157 /// What is wrong.
158 pub kind: AnalyticsDiagnosticKind,
159 /// The stable id of the scope the finding is about.
160 pub scope: String,
161 /// The day the finding is about, when it is about one.
162 pub date: Option<String>,
163 /// The stable event id the finding is about, when it is about one.
164 pub event_id: Option<String>,
165 /// What is wrong, in one sentence — the reason half of the #1643 shape.
166 pub message: String,
167 /// What the reader can do about it — the remedy half of the #1643 shape.
168 pub remedy: Option<String>,
169}
170
171impl AnalyticsDiagnostic {
172 /// A finding with its reason; the remedy comes from
173 /// [`AnalyticsDiagnosticKind::default_remedy`].
174 pub fn new(
175 kind: AnalyticsDiagnosticKind,
176 scope: impl Into<String>,
177 message: impl Into<String>,
178 ) -> Self {
179 Self {
180 kind,
181 scope: scope.into(),
182 date: None,
183 event_id: None,
184 message: message.into(),
185 remedy: kind.default_remedy().map(str::to_string),
186 }
187 }
188
189 /// Place the finding on a day.
190 pub fn on(mut self, date: impl Into<String>) -> Self {
191 self.date = Some(date.into());
192 self
193 }
194
195 /// Name the recorded event the finding is about.
196 pub fn about_event(mut self, event_id: impl Into<String>) -> Self {
197 self.event_id = Some(event_id.into());
198 self
199 }
200
201 /// Replace the kind's default remedy with one this finding can be more exact
202 /// about.
203 pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
204 self.remedy = Some(remedy.into());
205 self
206 }
207
208 /// Render through the crate's one renderer — identical bytes on every surface.
209 pub fn render(&self) -> String {
210 let mut diagnostic = Diagnostic::warning("E-tix-analytics", self.message.clone())
211 .with_note(format!("{}: {}", self.kind.as_str(), self.scope));
212 if let Some(date) = self.date.as_deref() {
213 diagnostic = diagnostic.with_note(format!("date: {date}"));
214 }
215 if let Some(event_id) = self.event_id.as_deref() {
216 diagnostic = diagnostic.with_note(format!("event: {event_id}"));
217 }
218 match self.remedy.as_deref() {
219 Some(remedy) => diagnostic.with_help(remedy).render(),
220 None => diagnostic.render(),
221 }
222 }
223}
224
225impl fmt::Display for AnalyticsDiagnostic {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 write!(f, "{}: {}", self.kind, self.message)
228 }
229}