Skip to main content

tix/health/
diagnostics.rs

1//! The health diagnostic vocabulary: one closed set of kinds, with the context a
2//! surface needs to act on them (mission `project-issue-health-status-01M3C8HX`,
3//! WP04 T019).
4//!
5//! # One vocabulary, four surfaces
6//!
7//! The data model fixes nine stable names — `unknown_health`, `missing_child`,
8//! `missing_project`, `missing_epic`, `missing_cycle`, `missing_milestone`,
9//! `invalid_state`, `policy_mismatch`, `unsupported_remote_field` — and requires the
10//! **same kind and context** in the CLI, the TUI, MCP, and the portable and remote
11//! output (C-007). That is why this module exists instead of each surface inventing
12//! a message: a health problem is a [`HealthDiagnostic`] value, constructed once by
13//! the index or the mapping, and every surface renders *that* value.
14//!
15//! # The context is the point
16//!
17//! A diagnostic that says "something is wrong" is a shrug. Each one therefore
18//! carries the scope it is about, the reference or remote field that caused it, the
19//! raw state when there is one, and a remedy — the sentence that tells the reader
20//! what to do. Rendering goes through the crate's one renderer
21//! ([`crate::diagnostics::Diagnostic`]), so the same value prints identically
22//! everywhere and is testable without a terminal.
23//!
24//! # Severity, deliberately
25//!
26//! Every health diagnostic is a **warning**: health is metadata, and a missing
27//! cycle reference means a rollup cannot count something — not that the repository
28//! is broken (C-006). Nothing here fails a command or blocks a move.
29
30use crate::diagnostics::Diagnostic;
31
32use super::events::HealthScope;
33
34/// A remote target health can be mapped to.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
36pub enum RemoteTarget {
37    /// The local portable DTO — carries everything, losslessly.
38    Portable,
39    /// GitLab issues.
40    GitLab,
41    /// Linear issues.
42    Linear,
43}
44
45impl RemoteTarget {
46    /// The stable spelling written to the portable document and printed by surfaces.
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Portable => "portable",
50            Self::GitLab => "gitlab",
51            Self::Linear => "linear",
52        }
53    }
54
55    /// Every target, in a stable order.
56    pub const ALL: [Self; 3] = [Self::Portable, Self::GitLab, Self::Linear];
57}
58
59impl std::fmt::Display for RemoteTarget {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.write_str(self.as_str())
62    }
63}
64
65/// The stable contract names for health diagnostics (the data-model's closed set).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
67pub enum HealthDiagnosticKind {
68    /// A contributor whose health nobody has recorded, or which cannot be read.
69    UnknownHealth,
70    /// A parent or child reference that resolves to nothing.
71    MissingChild,
72    /// A member naming a project the repository does not declare.
73    MissingProject,
74    /// A member naming an epic the repository does not declare.
75    MissingEpic,
76    /// A member naming a cycle the repository does not declare.
77    MissingCycle,
78    /// A member naming a milestone the repository does not declare.
79    MissingMilestone,
80    /// A stored state this build cannot read.
81    InvalidState,
82    /// A stored derivation whose policy version this build does not implement.
83    PolicyMismatch,
84    /// A health value a remote target cannot carry.
85    UnsupportedRemoteField,
86}
87
88impl HealthDiagnosticKind {
89    /// The stable spelling every surface prints and matches on.
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::UnknownHealth => "unknown_health",
93            Self::MissingChild => "missing_child",
94            Self::MissingProject => "missing_project",
95            Self::MissingEpic => "missing_epic",
96            Self::MissingCycle => "missing_cycle",
97            Self::MissingMilestone => "missing_milestone",
98            Self::InvalidState => "invalid_state",
99            Self::PolicyMismatch => "policy_mismatch",
100            Self::UnsupportedRemoteField => "unsupported_remote_field",
101        }
102    }
103
104    /// Every kind, in a stable order — for a refusal or a parity test to quote.
105    pub const ALL: [Self; 9] = [
106        Self::UnknownHealth,
107        Self::MissingChild,
108        Self::MissingProject,
109        Self::MissingEpic,
110        Self::MissingCycle,
111        Self::MissingMilestone,
112        Self::InvalidState,
113        Self::PolicyMismatch,
114        Self::UnsupportedRemoteField,
115    ];
116
117    /// The remedy that fits this kind, when the caller has nothing more specific.
118    ///
119    /// A remedy is what makes a diagnostic actionable: "a cycle is missing" tells a
120    /// reader what is wrong, "declare the cycle, or remove `cycles:` from the issue"
121    /// tells them what to do. The table lives here so all four surfaces say the same
122    /// thing (C-007).
123    pub const fn default_remedy(self) -> Option<&'static str> {
124        match self {
125            Self::UnknownHealth => Some(UNKNOWN_HEALTH_REMEDY),
126            Self::MissingChild => {
127                Some("remove the `parent:` reference, or create the parent issue")
128            }
129            Self::MissingProject => Some("declare the project, or remove the project membership"),
130            Self::MissingEpic => Some("declare the epic, or remove the epic membership"),
131            Self::MissingCycle => Some("declare the cycle, or remove `cycles:` from the issue"),
132            Self::MissingMilestone => {
133                Some("declare the milestone, or remove `milestones:` from the issue")
134            }
135            Self::InvalidState => Some(INVALID_STATE_REMEDY),
136            Self::PolicyMismatch => {
137                Some("re-derive the value under a policy version this build implements")
138            }
139            Self::UnsupportedRemoteField => {
140                Some("health stays local; a remote that lacks the field never clears it")
141            }
142        }
143    }
144
145    /// The kind for a scope that cannot be resolved.
146    pub const fn for_scope(scope: &HealthScope) -> Self {
147        match scope {
148            HealthScope::Issue(_) => Self::MissingChild,
149            HealthScope::Project(_) => Self::MissingProject,
150            HealthScope::Epic(_) => Self::MissingEpic,
151            HealthScope::Cycle(_) => Self::MissingCycle,
152            HealthScope::Milestone(_) => Self::MissingMilestone,
153        }
154    }
155}
156
157impl std::fmt::Display for HealthDiagnosticKind {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.write_str(self.as_str())
160    }
161}
162
163/// One health problem, with everything a surface needs to explain it (T019).
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct HealthDiagnostic {
166    /// What is wrong.
167    pub kind: HealthDiagnosticKind,
168    /// The scope it is about. Always a stable id (C-002).
169    pub scope: HealthScope,
170    /// The reference that could not be resolved, when the problem is a reference.
171    pub reference: Option<String>,
172    /// The field the problem sits at — a frontmatter key or a remote field name.
173    pub field: Option<String>,
174    /// The remote target, when the problem is about a mapping.
175    pub target: Option<RemoteTarget>,
176    /// The raw stored state, when the file records one this build cannot read.
177    pub state: Option<String>,
178    /// What is wrong, in one sentence.
179    pub message: String,
180    /// What the reader can do about it.
181    pub remedy: Option<String>,
182}
183
184impl HealthDiagnostic {
185    /// A diagnostic with its message; the context is added by the builder methods.
186    pub fn new(kind: HealthDiagnosticKind, scope: HealthScope, message: impl Into<String>) -> Self {
187        Self {
188            kind,
189            scope,
190            reference: None,
191            field: None,
192            target: None,
193            state: None,
194            message: message.into(),
195            remedy: kind.default_remedy().map(str::to_string),
196        }
197    }
198
199    /// Name the reference that could not be resolved.
200    pub fn referencing(mut self, reference: impl Into<String>) -> Self {
201        self.reference = Some(reference.into());
202        self
203    }
204
205    /// Name the field the problem sits at.
206    pub fn at_field(mut self, field: impl Into<String>) -> Self {
207        self.field = Some(field.into());
208        self
209    }
210
211    /// Name the remote target involved.
212    pub fn for_target(mut self, target: RemoteTarget) -> Self {
213        self.target = Some(target);
214        self
215    }
216
217    /// Record the raw state the file carries.
218    pub fn carrying(mut self, state: impl Into<String>) -> Self {
219        self.state = Some(state.into());
220        self
221    }
222
223    /// Say what to do about it.
224    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
225        self.remedy = Some(remedy.into());
226        self
227    }
228
229    /// Render through the crate's one renderer — identical bytes on every surface.
230    pub fn render(&self) -> String {
231        let mut diagnostic = Diagnostic::warning("E-tix-health", self.message.clone())
232            .with_note(format!("{}: {}", self.kind.as_str(), self.scope));
233        if let Some(reference) = &self.reference {
234            diagnostic = diagnostic.with_note(format!("reference: {reference}"));
235        }
236        if let Some(field) = &self.field {
237            diagnostic = diagnostic.with_note(format!("field: {field}"));
238        }
239        if let Some(target) = self.target {
240            diagnostic = diagnostic.with_note(format!("target: {target}"));
241        }
242        if let Some(state) = &self.state {
243            diagnostic = diagnostic.with_note(format!("recorded: {state}"));
244        }
245        if let Some(remedy) = &self.remedy {
246            diagnostic = diagnostic.with_help(remedy.clone());
247        }
248        diagnostic.render()
249    }
250
251    /// Whether two diagnostics describe the same problem.
252    ///
253    /// Used to make a merge idempotent: a diagnostic a previous round trip already
254    /// stored is *recognized* rather than appended a second time, which is what
255    /// makes `document → JSON → document → JSON` byte-stable (T021).
256    pub fn same_as(&self, other: &Self) -> bool {
257        self.kind == other.kind
258            && self.scope == other.scope
259            && self.reference == other.reference
260            && self.field == other.field
261            && self.target == other.target
262            && self.state == other.state
263            && self.message == other.message
264            && self.remedy == other.remedy
265    }
266
267    /// The one line the portable document carries, so a round trip can be checked
268    /// without comparing structs the reader cannot see.
269    pub fn describe(&self) -> String {
270        format!("{} {} {}", self.kind.as_str(), self.scope, self.message)
271    }
272}
273
274/// The remedy for a state the file records but this build cannot read.
275pub const INVALID_STATE_REMEDY: &str =
276    "set `health.state` to one of unknown, on_track, needs_attention, at_risk";
277
278/// The remedy for a contributor that records no health at all.
279pub const UNKNOWN_HEALTH_REMEDY: &str =
280    "record `health.state` for the issue, or accept that this scope's health is unknown";
281
282#[cfg(test)]
283mod tests {
284    #[allow(clippy::wildcard_imports)]
285    use super::*;
286
287    #[test]
288    fn health_diagnostic_kinds_are_the_data_models_closed_set() {
289        let names: Vec<&str> = HealthDiagnosticKind::ALL
290            .iter()
291            .map(|k| k.as_str())
292            .collect();
293        assert_eq!(
294            names,
295            vec![
296                "unknown_health",
297                "missing_child",
298                "missing_project",
299                "missing_epic",
300                "missing_cycle",
301                "missing_milestone",
302                "invalid_state",
303                "policy_mismatch",
304                "unsupported_remote_field",
305            ]
306        );
307        assert_eq!(
308            HealthDiagnosticKind::for_scope(&HealthScope::Issue(1)).as_str(),
309            "missing_child"
310        );
311        assert_eq!(
312            HealthDiagnosticKind::for_scope(&HealthScope::Milestone("m".into())).as_str(),
313            "missing_milestone"
314        );
315        assert_eq!(RemoteTarget::ALL[2].as_str(), "linear");
316    }
317
318    #[test]
319    fn health_diagnostic_renders_its_context_and_is_idempotent_by_value() {
320        let diagnostic = HealthDiagnostic::new(
321            HealthDiagnosticKind::UnsupportedRemoteField,
322            HealthScope::Issue(7),
323            "gitlab cannot carry a health state",
324        )
325        .referencing("issue:7")
326        .at_field("health")
327        .for_target(RemoteTarget::GitLab)
328        .carrying("at_risk")
329        .with_remedy("health stays local");
330        let rendered = diagnostic.render();
331        assert!(rendered.contains("unsupported_remote_field"), "{rendered}");
332        assert!(rendered.contains("issue:7"), "{rendered}");
333        assert!(rendered.contains("field: health"), "{rendered}");
334        assert!(rendered.contains("target: gitlab"), "{rendered}");
335        assert!(rendered.contains("recorded: at_risk"), "{rendered}");
336        assert!(rendered.contains("health stays local"), "{rendered}");
337        assert!(diagnostic.same_as(&diagnostic.clone()));
338        assert!(
339            diagnostic
340                .describe()
341                .starts_with("unsupported_remote_field issue:7")
342        );
343        assert!(!diagnostic.same_as(&HealthDiagnostic::new(
344            HealthDiagnosticKind::InvalidState,
345            HealthScope::Issue(7),
346            "gitlab cannot carry a health state"
347        )));
348    }
349}