1use crate::diagnostics::Diagnostic;
31
32use super::events::HealthScope;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
36pub enum RemoteTarget {
37 Portable,
39 GitLab,
41 Linear,
43}
44
45impl RemoteTarget {
46 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
67pub enum HealthDiagnosticKind {
68 UnknownHealth,
70 MissingChild,
72 MissingProject,
74 MissingEpic,
76 MissingCycle,
78 MissingMilestone,
80 InvalidState,
82 PolicyMismatch,
84 UnsupportedRemoteField,
86}
87
88impl HealthDiagnosticKind {
89 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct HealthDiagnostic {
166 pub kind: HealthDiagnosticKind,
168 pub scope: HealthScope,
170 pub reference: Option<String>,
172 pub field: Option<String>,
174 pub target: Option<RemoteTarget>,
176 pub state: Option<String>,
178 pub message: String,
180 pub remedy: Option<String>,
182}
183
184impl HealthDiagnostic {
185 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 pub fn referencing(mut self, reference: impl Into<String>) -> Self {
201 self.reference = Some(reference.into());
202 self
203 }
204
205 pub fn at_field(mut self, field: impl Into<String>) -> Self {
207 self.field = Some(field.into());
208 self
209 }
210
211 pub fn for_target(mut self, target: RemoteTarget) -> Self {
213 self.target = Some(target);
214 self
215 }
216
217 pub fn carrying(mut self, state: impl Into<String>) -> Self {
219 self.state = Some(state.into());
220 self
221 }
222
223 pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
225 self.remedy = Some(remedy.into());
226 self
227 }
228
229 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 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 pub fn describe(&self) -> String {
270 format!("{} {} {}", self.kind.as_str(), self.scope, self.message)
271 }
272}
273
274pub const INVALID_STATE_REMEDY: &str =
276 "set `health.state` to one of unknown, on_track, needs_attention, at_risk";
277
278pub 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}