use crate::diagnostics::Diagnostic;
use super::events::HealthScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RemoteTarget {
Portable,
GitLab,
Linear,
}
impl RemoteTarget {
pub const fn as_str(self) -> &'static str {
match self {
Self::Portable => "portable",
Self::GitLab => "gitlab",
Self::Linear => "linear",
}
}
pub const ALL: [Self; 3] = [Self::Portable, Self::GitLab, Self::Linear];
}
impl std::fmt::Display for RemoteTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum HealthDiagnosticKind {
UnknownHealth,
MissingChild,
MissingProject,
MissingEpic,
MissingCycle,
MissingMilestone,
InvalidState,
PolicyMismatch,
UnsupportedRemoteField,
}
impl HealthDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::UnknownHealth => "unknown_health",
Self::MissingChild => "missing_child",
Self::MissingProject => "missing_project",
Self::MissingEpic => "missing_epic",
Self::MissingCycle => "missing_cycle",
Self::MissingMilestone => "missing_milestone",
Self::InvalidState => "invalid_state",
Self::PolicyMismatch => "policy_mismatch",
Self::UnsupportedRemoteField => "unsupported_remote_field",
}
}
pub const ALL: [Self; 9] = [
Self::UnknownHealth,
Self::MissingChild,
Self::MissingProject,
Self::MissingEpic,
Self::MissingCycle,
Self::MissingMilestone,
Self::InvalidState,
Self::PolicyMismatch,
Self::UnsupportedRemoteField,
];
pub const fn default_remedy(self) -> Option<&'static str> {
match self {
Self::UnknownHealth => Some(UNKNOWN_HEALTH_REMEDY),
Self::MissingChild => {
Some("remove the `parent:` reference, or create the parent issue")
}
Self::MissingProject => Some("declare the project, or remove the project membership"),
Self::MissingEpic => Some("declare the epic, or remove the epic membership"),
Self::MissingCycle => Some("declare the cycle, or remove `cycles:` from the issue"),
Self::MissingMilestone => {
Some("declare the milestone, or remove `milestones:` from the issue")
}
Self::InvalidState => Some(INVALID_STATE_REMEDY),
Self::PolicyMismatch => {
Some("re-derive the value under a policy version this build implements")
}
Self::UnsupportedRemoteField => {
Some("health stays local; a remote that lacks the field never clears it")
}
}
}
pub const fn for_scope(scope: &HealthScope) -> Self {
match scope {
HealthScope::Issue(_) => Self::MissingChild,
HealthScope::Project(_) => Self::MissingProject,
HealthScope::Epic(_) => Self::MissingEpic,
HealthScope::Cycle(_) => Self::MissingCycle,
HealthScope::Milestone(_) => Self::MissingMilestone,
}
}
}
impl std::fmt::Display for HealthDiagnosticKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthDiagnostic {
pub kind: HealthDiagnosticKind,
pub scope: HealthScope,
pub reference: Option<String>,
pub field: Option<String>,
pub target: Option<RemoteTarget>,
pub state: Option<String>,
pub message: String,
pub remedy: Option<String>,
}
impl HealthDiagnostic {
pub fn new(kind: HealthDiagnosticKind, scope: HealthScope, message: impl Into<String>) -> Self {
Self {
kind,
scope,
reference: None,
field: None,
target: None,
state: None,
message: message.into(),
remedy: kind.default_remedy().map(str::to_string),
}
}
pub fn referencing(mut self, reference: impl Into<String>) -> Self {
self.reference = Some(reference.into());
self
}
pub fn at_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
pub fn for_target(mut self, target: RemoteTarget) -> Self {
self.target = Some(target);
self
}
pub fn carrying(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
self.remedy = Some(remedy.into());
self
}
pub fn render(&self) -> String {
let mut diagnostic = Diagnostic::warning("E-tix-health", self.message.clone())
.with_note(format!("{}: {}", self.kind.as_str(), self.scope));
if let Some(reference) = &self.reference {
diagnostic = diagnostic.with_note(format!("reference: {reference}"));
}
if let Some(field) = &self.field {
diagnostic = diagnostic.with_note(format!("field: {field}"));
}
if let Some(target) = self.target {
diagnostic = diagnostic.with_note(format!("target: {target}"));
}
if let Some(state) = &self.state {
diagnostic = diagnostic.with_note(format!("recorded: {state}"));
}
if let Some(remedy) = &self.remedy {
diagnostic = diagnostic.with_help(remedy.clone());
}
diagnostic.render()
}
pub fn same_as(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.scope == other.scope
&& self.reference == other.reference
&& self.field == other.field
&& self.target == other.target
&& self.state == other.state
&& self.message == other.message
&& self.remedy == other.remedy
}
pub fn describe(&self) -> String {
format!("{} {} {}", self.kind.as_str(), self.scope, self.message)
}
}
pub const INVALID_STATE_REMEDY: &str =
"set `health.state` to one of unknown, on_track, needs_attention, at_risk";
pub const UNKNOWN_HEALTH_REMEDY: &str =
"record `health.state` for the issue, or accept that this scope's health is unknown";
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn health_diagnostic_kinds_are_the_data_models_closed_set() {
let names: Vec<&str> = HealthDiagnosticKind::ALL
.iter()
.map(|k| k.as_str())
.collect();
assert_eq!(
names,
vec![
"unknown_health",
"missing_child",
"missing_project",
"missing_epic",
"missing_cycle",
"missing_milestone",
"invalid_state",
"policy_mismatch",
"unsupported_remote_field",
]
);
assert_eq!(
HealthDiagnosticKind::for_scope(&HealthScope::Issue(1)).as_str(),
"missing_child"
);
assert_eq!(
HealthDiagnosticKind::for_scope(&HealthScope::Milestone("m".into())).as_str(),
"missing_milestone"
);
assert_eq!(RemoteTarget::ALL[2].as_str(), "linear");
}
#[test]
fn health_diagnostic_renders_its_context_and_is_idempotent_by_value() {
let diagnostic = HealthDiagnostic::new(
HealthDiagnosticKind::UnsupportedRemoteField,
HealthScope::Issue(7),
"gitlab cannot carry a health state",
)
.referencing("issue:7")
.at_field("health")
.for_target(RemoteTarget::GitLab)
.carrying("at_risk")
.with_remedy("health stays local");
let rendered = diagnostic.render();
assert!(rendered.contains("unsupported_remote_field"), "{rendered}");
assert!(rendered.contains("issue:7"), "{rendered}");
assert!(rendered.contains("field: health"), "{rendered}");
assert!(rendered.contains("target: gitlab"), "{rendered}");
assert!(rendered.contains("recorded: at_risk"), "{rendered}");
assert!(rendered.contains("health stays local"), "{rendered}");
assert!(diagnostic.same_as(&diagnostic.clone()));
assert!(
diagnostic
.describe()
.starts_with("unsupported_remote_field issue:7")
);
assert!(!diagnostic.same_as(&HealthDiagnostic::new(
HealthDiagnosticKind::InvalidState,
HealthScope::Issue(7),
"gitlab cannot carry a health state"
)));
}
}