use std::fmt;
use crate::model::{HistoryEventKind, Issue};
use super::policy::{HealthPolicy, HealthPolicyError};
use super::state::{HealthState, StateParseError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HealthSource {
Current,
Derived,
Override,
}
impl HealthSource {
pub const fn as_str(self) -> &'static str {
match self {
Self::Current => "current",
Self::Derived => "derived",
Self::Override => "override",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"current" => Some(Self::Current),
"derived" => Some(Self::Derived),
"override" => Some(Self::Override),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HealthScope {
Issue(u32),
Project(String),
Epic(String),
Cycle(String),
Milestone(String),
}
impl HealthScope {
pub fn as_str(&self) -> String {
match self {
Self::Issue(number) => format!("issue:{number}"),
Self::Project(id) => format!("project:{id}"),
Self::Epic(id) => format!("epic:{id}"),
Self::Cycle(id) => format!("cycle:{id}"),
Self::Milestone(id) => format!("milestone:{id}"),
}
}
pub const fn of_issue(number: u32) -> Self {
Self::Issue(number)
}
pub fn parse(raw: &str) -> Option<Self> {
let (kind, id) = raw.trim().split_once(':')?;
let id = id.trim();
if id.is_empty() {
return None;
}
match kind.trim().to_ascii_lowercase().as_str() {
"issue" => id.parse::<u32>().ok().map(Self::Issue),
"project" => Some(Self::Project(id.to_string())),
"epic" => Some(Self::Epic(id.to_string())),
"cycle" => Some(Self::Cycle(id.to_string())),
"milestone" => Some(Self::Milestone(id.to_string())),
_ => None,
}
}
}
impl fmt::Display for HealthScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthUpdate {
pub scope: HealthScope,
pub from: Option<HealthState>,
pub to: HealthState,
pub actor: String,
pub timestamp: String,
pub rationale: String,
pub policy_version: String,
pub source: HealthSource,
}
impl HealthUpdate {
pub fn new(
scope: HealthScope,
to: HealthState,
actor: impl Into<String>,
timestamp: impl Into<String>,
rationale: impl Into<String>,
) -> Self {
Self {
scope,
from: None,
to,
actor: actor.into(),
timestamp: timestamp.into(),
rationale: rationale.into(),
policy_version: super::policy::HEALTH_POLICY_VERSION.to_string(),
source: HealthSource::Current,
}
}
pub fn from(mut self, from: HealthState) -> Self {
self.from = Some(from);
self
}
pub fn derived(mut self) -> Self {
self.source = HealthSource::Derived;
self
}
pub fn overriding(mut self) -> Self {
self.source = HealthSource::Override;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthOverride {
pub update: HealthUpdate,
pub derived_value: Option<HealthState>,
}
impl HealthOverride {
pub fn new(
scope: HealthScope,
state: HealthState,
actor: impl Into<String>,
timestamp: impl Into<String>,
rationale: impl Into<String>,
) -> Self {
Self {
update: HealthUpdate::new(scope, state, actor, timestamp, rationale).overriding(),
derived_value: None,
}
}
pub fn over(mut self, derived: HealthState) -> Self {
self.derived_value = Some(derived);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HealthError {
BlankActor,
BlankTimestamp,
BlankRationale,
InvalidScope(String),
UnknownPolicy(HealthPolicyError),
InvalidState(StateParseError),
}
impl HealthError {
pub fn render(&self) -> String {
match self {
Self::BlankActor => {
"a health change needs an actor; a blank one is not an actor".to_string()
}
Self::BlankTimestamp => {
"a health change needs a timestamp; a blank one is not a moment".to_string()
}
Self::BlankRationale => {
"a health change needs a rationale; a blank one is not a reason (C-004)".to_string()
}
Self::InvalidScope(raw) => format!(
"`{raw}` is not a health scope; scopes are `issue:<n>`, `project:<id>`, \
`epic:<id>`, `cycle:<id>`, `milestone:<id>`"
),
Self::UnknownPolicy(error) => error.render(),
Self::InvalidState(error) => error.render(),
}
}
}
impl fmt::Display for HealthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
impl std::error::Error for HealthError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthEvent {
pub kind: HistoryEventKind,
pub actor: String,
pub timestamp: String,
pub detail: String,
}
pub fn validate(
update: &HealthUpdate,
policy: &HealthPolicy,
) -> std::result::Result<(), HealthError> {
for (value, error) in [
(update.actor.trim(), HealthError::BlankActor),
(update.timestamp.trim(), HealthError::BlankTimestamp),
(update.rationale.trim(), HealthError::BlankRationale),
] {
if value.is_empty() {
return Err(error);
}
}
if update.policy_version.trim() != policy.version() {
return Err(HealthError::UnknownPolicy(HealthPolicyError {
version: update.policy_version.clone(),
}));
}
Ok(())
}
pub fn event(
update: &HealthUpdate,
policy: &HealthPolicy,
) -> std::result::Result<HealthEvent, HealthError> {
validate(update, policy)?;
let kind = match update.source {
HealthSource::Override => HistoryEventKind::HealthOverridden,
HealthSource::Current | HealthSource::Derived => HistoryEventKind::HealthUpdated,
};
let mut detail = format!(
"health {} -> {} on `{}` (policy {}, source {})",
update
.from
.map_or("(unset)".to_string(), |state| state.as_str().to_string()),
update.to.as_str(),
update.scope,
update.policy_version,
update.source.as_str(),
);
detail.push_str(&format!(": {}", update.rationale.trim()));
Ok(HealthEvent {
kind,
actor: update.actor.trim().to_string(),
timestamp: update.timestamp.trim().to_string(),
detail,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedHealth {
pub issue: Issue,
pub event: HealthEvent,
}
pub fn apply(
store: &crate::storage::Store,
number: u32,
update: &HealthUpdate,
policy: &HealthPolicy,
) -> crate::error::Result<AppliedHealth> {
let event =
event(update, policy).map_err(|error| crate::error::TixError::Refused(error.render()))?;
let mut issue = store.get(crate::storage::IssueSelector::Number(number))?;
issue.history.append(crate::model::HistoryEvent::new(
event.timestamp.clone(),
&event.actor,
event.kind,
&event.detail,
));
issue.health = Some(crate::model::HealthRef {
state: Some(update.to.as_str().to_string()),
source: Some(update.source.as_str().to_string()),
policy_version: Some(update.policy_version.clone()),
updated_at: Some(event.timestamp.clone()),
});
issue.bump_updated();
issue.filename = store.update(&issue.filename, &issue)?;
Ok(AppliedHealth { issue, event })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthView {
pub state: HealthState,
pub source: HealthSource,
pub recorded: Option<String>,
pub policy_version: Option<String>,
pub updated_at: Option<String>,
pub diagnostic: Option<HealthError>,
}
impl HealthView {
pub fn is_known(&self) -> bool {
self.state != HealthState::Unknown
}
pub fn render(&self) -> String {
if let Some(error) = &self.diagnostic {
return error.render();
}
match self.updated_at.as_deref() {
Some(when) => format!(
"{} ({} since {when})",
self.state.label(),
self.source.as_str()
),
None => format!("{} ({})", self.state.label(), self.source.as_str()),
}
}
}
pub fn project(issue: &Issue, _policy: &HealthPolicy) -> HealthView {
let Some(reference) = issue.health.as_ref() else {
return HealthView {
state: HealthState::Unknown,
source: HealthSource::Current,
recorded: None,
policy_version: None,
updated_at: None,
diagnostic: None,
};
};
let source = reference
.source
.as_deref()
.and_then(HealthSource::parse)
.unwrap_or(HealthSource::Current);
let state = match reference.state.as_deref() {
None => HealthState::Unknown,
Some(raw) => match HealthState::parse(raw) {
Ok(state) => state,
Err(error) => {
return HealthView {
state: HealthState::Unknown,
source,
recorded: Some(raw.to_string()),
policy_version: reference.policy_version.clone(),
updated_at: reference.updated_at.clone(),
diagnostic: Some(HealthError::InvalidState(error)),
};
}
},
};
let diagnostic = match (source, reference.policy_version.as_deref()) {
(HealthSource::Derived | HealthSource::Override, None) => {
Some(HealthError::UnknownPolicy(HealthPolicyError {
version: "(none recorded)".to_string(),
}))
}
_ => None,
};
HealthView {
state,
source,
recorded: reference.state.clone(),
policy_version: reference.policy_version.clone(),
updated_at: reference.updated_at.clone(),
diagnostic,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{HealthRef, Issue};
fn issue_with(health: Option<HealthRef>) -> Issue {
let contents = "---\nnumber: 7\ntitle: \"Fixture\"\nstatus: \"todo\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n";
let mut issue = Issue::parse(contents).expect("the fixture parses");
issue.filename = "fixture.md".to_string();
issue.health = health;
issue
}
fn update() -> HealthUpdate {
HealthUpdate::new(
HealthScope::of_issue(7),
HealthState::AtRisk,
"alice",
"2026-01-02T03:04:05Z",
"the migration slipped",
)
.from(HealthState::OnTrack)
}
#[test]
fn health_scopes_round_trip_their_canonical_spelling() {
for scope in [
HealthScope::Issue(42),
HealthScope::Project("alpha".to_string()),
HealthScope::Epic("epic-1".to_string()),
HealthScope::Cycle("2026-01".to_string()),
HealthScope::Milestone("m1".to_string()),
] {
assert_eq!(HealthScope::parse(&scope.as_str()).unwrap(), scope);
}
assert_eq!(HealthScope::Issue(42).as_str(), "issue:42");
assert!(HealthScope::parse("issue:").is_none());
assert!(HealthScope::parse("sprint:1").is_none());
assert!(HealthScope::parse("issue:abc").is_none());
}
#[test]
fn health_validation_refuses_a_change_that_cannot_be_attributed() {
let policy = HealthPolicy::v1();
assert!(validate(&update(), &policy).is_ok());
for (mut broken, expected) in [
(
{
let mut u = update();
u.actor = " ".into();
u
},
HealthError::BlankActor,
),
(
{
let mut u = update();
u.timestamp = String::new();
u
},
HealthError::BlankTimestamp,
),
(
{
let mut u = update();
u.rationale = "\t".into();
u
},
HealthError::BlankRationale,
),
(
{
let mut u = update();
u.policy_version = "health-v2".into();
u
},
HealthError::UnknownPolicy(HealthPolicyError {
version: "health-v2".into(),
}),
),
] {
let error = validate(&broken, &policy).expect_err("refused");
assert_eq!(error, expected, "{broken:?}");
assert_ne!(error.render(), "");
assert!(event(&broken, &policy).is_err());
broken.actor.clear();
}
}
#[test]
fn health_event_records_the_whole_change_in_one_entry() {
let policy = HealthPolicy::v1();
let built = event(&update(), &policy).expect("a complete change");
assert_eq!(built.kind, HistoryEventKind::HealthUpdated);
assert_eq!(built.actor, "alice");
assert_eq!(built.timestamp, "2026-01-02T03:04:05Z");
for needle in [
"on_track -> at_risk",
"`issue:7`",
"policy health-v1",
"source current",
"the migration slipped",
] {
assert!(
built.detail.contains(needle),
"{needle:?} in {}",
built.detail
);
}
let over = HealthOverride::new(
HealthScope::Project("alpha".to_string()),
HealthState::OnTrack,
"bob",
"2026-01-03T00:00:00Z",
"we accepted the risk",
)
.over(HealthState::AtRisk);
let built = event(&over.update, &policy).expect("a complete override");
assert_eq!(built.kind, HistoryEventKind::HealthOverridden);
assert!(built.detail.contains("source override"), "{}", built.detail);
assert!(built.detail.contains("`project:alpha`"), "{}", built.detail);
}
#[test]
fn health_projection_is_total_and_explains_what_it_cannot_read() {
let policy = HealthPolicy::v1();
let legacy = project(&issue_with(None), &policy);
assert_eq!(legacy.state, HealthState::Unknown);
assert!(!legacy.is_known());
assert!(legacy.recorded.is_none() && legacy.diagnostic.is_none());
assert!(legacy.render().contains("Unknown"), "{}", legacy.render());
let known = project(
&issue_with(Some(HealthRef {
state: Some("on_track".to_string()),
source: Some("current".to_string()),
policy_version: None,
updated_at: Some("2026-01-02".to_string()),
})),
&policy,
);
assert_eq!(known.state, HealthState::OnTrack);
assert_eq!(known.source, HealthSource::Current);
assert!(known.is_known());
assert!(
known.render().contains("since 2026-01-02"),
"{}",
known.render()
);
let unreadable = project(
&issue_with(Some(HealthRef {
state: Some("fine".to_string()),
..HealthRef::default()
})),
&policy,
);
assert_eq!(unreadable.state, HealthState::Unknown);
assert_eq!(unreadable.recorded.as_deref(), Some("fine"));
assert!(
unreadable.render().contains("fine"),
"{}",
unreadable.render()
);
assert!(
unreadable.render().contains("on_track"),
"{}",
unreadable.render()
);
let derived = project(
&issue_with(Some(HealthRef {
state: Some("at_risk".to_string()),
source: Some("derived".to_string()),
..HealthRef::default()
})),
&policy,
);
assert_eq!(
derived.state,
HealthState::AtRisk,
"the value is still shown"
);
assert!(
derived.diagnostic.is_some(),
"and the missing version is reported"
);
assert!(
derived.render().contains("health-v1"),
"{}",
derived.render()
);
}
}