use std::collections::BTreeMap;
use crate::cli::HealthAction;
use crate::commands::export::run_health_core;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::health::{
HealthPolicy, HealthScope, HealthSource, HealthState, HealthUpdate, PortableHealth,
PortableScopeHealth, apply, project, validate,
};
use crate::model::today;
use crate::storage::{Store, actor_identity};
fn policy() -> HealthPolicy {
HealthPolicy::v1()
}
pub fn run(config: &Config, store: &Store, action: &HealthAction) -> Result<()> {
match action {
HealthAction::Set {
issue,
state,
actor,
date,
rationale,
} => set(
store,
issue,
state,
actor.as_deref(),
date.as_deref(),
rationale.as_deref(),
),
HealthAction::Show { issue } => show(config, store, issue),
HealthAction::Rollup { scope, id } => {
let scope = parse_scope(scope, id)?;
let document = run_health_core(config)?;
print!("{}", render_rollup(&document, &scope));
Ok(())
}
HealthAction::Override {
scope,
id,
state,
actor,
date,
rationale,
confirm,
} => override_scope(
store,
scope,
id,
state,
actor.as_deref(),
date.as_deref(),
rationale.as_deref(),
*confirm,
),
HealthAction::History { scope } => {
let scope = parse_scope("", scope)?;
let document = run_health_core(config)?;
print!("{}", render_history(&document, &scope));
Ok(())
}
HealthAction::Board => {
let document = run_health_core(config)?;
print!("{}", render_board(&document));
Ok(())
}
}
}
#[allow(clippy::too_many_arguments)]
fn set(
store: &Store,
issue: &str,
state: &str,
actor: Option<&str>,
date: Option<&str>,
rationale: Option<&str>,
) -> Result<()> {
let number = store.resolve_issue(issue)?.number;
mutate(
store,
HealthScope::Issue(number),
state,
actor,
date,
rationale,
HealthSource::Current,
)
}
#[allow(clippy::too_many_arguments)]
fn override_scope(
store: &Store,
scope: &str,
id: &str,
state: &str,
actor: Option<&str>,
date: Option<&str>,
rationale: Option<&str>,
confirm: bool,
) -> Result<()> {
if !confirm {
return Err(TixError::InvalidInput(
"refusing to override derived health without --confirm: an override is an \
explicit act about a whole scope, and it layers over the derived value \
rather than replacing it"
.to_string(),
));
}
let scope = parse_scope(scope, id)?;
mutate(
store,
scope,
state,
actor,
date,
rationale,
HealthSource::Override,
)
}
pub fn record(
store: &Store,
scope: HealthScope,
state: &str,
actor: &str,
date: &str,
rationale: &str,
source: HealthSource,
) -> Result<crate::health::AppliedHealth> {
let HealthScope::Issue(number) = scope.clone() else {
return Err(TixError::InvalidInput(format!(
"`{scope}` has no first-class record in this build, so a health value \
written for it would have nowhere to live; only an issue carries a \
`health:` block"
)));
};
let to = HealthState::parse(state).map_err(|error| TixError::InvalidInput(error.render()))?;
let current = store
.get(crate::storage::IssueSelector::Number(number))
.map(|issue| project(&issue, &policy()).state)
.unwrap_or(HealthState::Unknown);
let mut update = HealthUpdate::new(
scope,
to,
actor.to_string(),
date.to_string(),
rationale.to_string(),
)
.from(current);
update.policy_version = policy().version().to_string();
update.source = source;
validate(&update, &policy()).map_err(|error| TixError::InvalidInput(error.render()))?;
apply(store, number, &update, &policy())
}
#[allow(clippy::too_many_arguments)]
fn mutate(
store: &Store,
scope: HealthScope,
state: &str,
actor: Option<&str>,
date: Option<&str>,
rationale: Option<&str>,
source: HealthSource,
) -> Result<()> {
let HealthScope::Issue(number) = scope.clone() else {
return Err(TixError::InvalidInput(format!(
"`{scope}` has no first-class record in this build, so a health value \
written for it would have nowhere to live; `health set`/`health override` \
record an issue's health (`--scope issue --id <n>`), and a parent's \
derived rollup is what `tix health rollup` reports"
)));
};
let actor = actor.map(str::to_string).unwrap_or_else(actor_identity);
let date = date.map(str::to_string).unwrap_or_else(today);
let applied = record(
store,
scope,
state,
&actor,
&date,
rationale.unwrap_or_default(),
source,
)?;
let stored = applied
.issue
.health
.as_ref()
.and_then(|health| health.state.clone())
.unwrap_or_default();
print!(
"{}",
render_mutation(number, &stored, source, policy().version())
);
Ok(())
}
fn show(config: &Config, store: &Store, issue: &str) -> Result<()> {
let number = store.resolve_issue(issue)?.number;
let document = run_health_core(config)?;
let scope = HealthScope::Issue(number);
if document.scope(&scope).is_none() {
return Err(TixError::NotFound(format!(
"no issue #{number} in the health document"
)));
}
print!("{}", render_show(&document, &scope));
Ok(())
}
pub fn render_show(document: &PortableHealth, scope: &HealthScope) -> String {
let mut out = match document.scope(scope) {
Some(entry) => render_scope(entry),
None => format!("{}: no health value\n", scope.as_str()),
};
match document.scope(scope) {
Some(entry) if entry.rollup.is_some() => {
let derived = entry.rollup.as_ref().expect("checked");
out.push_str(&format!("derived: {}\n", render_derived(derived)));
}
Some(_) => out.push_str("derived: (nothing contributes)\n"),
None => out.push_str("derived: (nothing contributes)\n"),
}
for diagnostic in document.diagnostics_for(scope) {
out.push_str(&format!("{}\n", diagnostic.to_diagnostic().render()));
}
out
}
pub fn render_mutation(
number: u32,
state: &str,
source: HealthSource,
policy_version: &str,
) -> String {
format!(
"issue:{number} {state} {} {policy_version}\nhistory: 1 entry appended\n",
source.as_str()
)
}
fn parse_scope(scope: &str, id: &str) -> Result<HealthScope> {
let kind = if scope.trim().is_empty() {
return HealthScope::parse(id).ok_or_else(|| {
TixError::InvalidInput(format!(
"`{id}` is not a scope; expected issue:<n>, project:<id>, epic:<id>, \
cycle:<id> or milestone:<id>"
))
});
} else {
scope.trim().to_ascii_lowercase()
};
let id = id.trim();
if id.is_empty() {
return Err(TixError::InvalidInput(format!(
"--scope {kind} needs a non-empty --id"
)));
}
match kind.as_str() {
"issue" => id
.parse::<u32>()
.map(HealthScope::Issue)
.map_err(|_| TixError::InvalidInput(format!("`{id}` is not an issue number"))),
"project" => Ok(HealthScope::Project(id.to_string())),
"epic" => Ok(HealthScope::Epic(id.to_string())),
"cycle" => Ok(HealthScope::Cycle(id.to_string())),
"milestone" => Ok(HealthScope::Milestone(id.to_string())),
other => Err(TixError::InvalidInput(format!(
"unknown health scope `{other}`; expected issue, project, epic, cycle or \
milestone"
))),
}
}
pub fn render_scope(entry: &PortableScopeHealth) -> String {
let mut line = format!(
"{:<16} {:<16} {:<8} {}",
entry.scope,
entry.state,
entry.source,
entry
.recorded_policy_version
.as_deref()
.unwrap_or("(no policy version recorded)")
);
if let Some(recorded) = &entry.recorded {
if recorded != &entry.state {
line.push_str(&format!(" recorded `{recorded}`"));
}
}
if let Some(updated) = &entry.updated_at {
line.push_str(&format!(" updated {updated}"));
}
let mut out = format!("{line}\n");
for event in &entry.events {
out.push_str(&format!(
" {} {} {} {}\n",
event.timestamp, event.actor, event.kind, event.detail
));
}
out
}
pub fn render_derived(rollup: &crate::health::PortableRollup) -> String {
let contributors: Vec<String> = rollup
.contributors
.iter()
.map(|contributor| format!("#{} {}", contributor.number, contributor.state))
.collect();
format!(
"{} ({}, {}, {} contributor{}: {})",
rollup.state,
rollup.policy_version,
rollup.source,
rollup.contributors.len(),
if rollup.contributors.len() == 1 {
""
} else {
"s"
},
if contributors.is_empty() {
"nothing contributes yet".to_string()
} else {
contributors.join(", ")
}
)
}
pub fn render_rollup(document: &PortableHealth, scope: &HealthScope) -> String {
let Some(entry) = document.scope(scope) else {
let mut out = format!("{}: no health value and no contributors\n", scope.as_str());
for diagnostic in document.diagnostics_for(scope) {
out.push_str(&format!("{}\n", diagnostic.to_diagnostic().render()));
}
return out;
};
let mut out = format!("{}\n", render_scope(entry).trim_end());
if let Some(derived) = &entry.rollup {
out.push_str(&format!("derived: {}\n", render_derived(derived)));
} else {
out.push_str("derived: (nothing contributes)\n");
}
for diagnostic in document.diagnostics_for(scope) {
out.push_str(&format!("{}\n", diagnostic.to_diagnostic().render()));
}
out
}
pub fn render_history(document: &PortableHealth, scope: &HealthScope) -> String {
let Some(entry) = document.scope(scope) else {
return format!("{}: no health history\n", scope.as_str());
};
if entry.events.is_empty() {
return format!(
"{}: no health history ({} {} {})\n",
entry.scope, entry.state, entry.source, "unknown until someone records it"
);
}
let mut out = format!("{}: {} entries\n", entry.scope, entry.events.len());
for event in &entry.events {
out.push_str(&format!(
"{} {} {} {}\n",
event.timestamp, event.actor, event.kind, event.detail
));
}
out
}
pub fn render_board(document: &PortableHealth) -> String {
let mut out = format!("HEALTH policy {}\n", document.policy_version);
let mut with_rollup: Vec<(&PortableScopeHealth, String)> = Vec::new();
for entry in &document.scopes {
match &entry.rollup {
Some(derived) => with_rollup.push((entry, render_derived(derived))),
None => out.push_str(&format!(
"{:<16} {:<16} {:<8}\n",
entry.scope, entry.state, entry.source
)),
}
}
for (entry, derived) in with_rollup {
out.push_str(&format!(
"{:<16} {:<16} {:<8} {}\n",
entry.scope, entry.state, entry.source, derived
));
}
if !document.diagnostics.is_empty() {
out.push_str(&format!("\n{} diagnostics\n", document.diagnostics.len()));
for diagnostic in &document.diagnostics {
out.push_str(&format!("{}\n", diagnostic.to_diagnostic().render()));
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HealthSummary {
pub issues: usize,
pub by_state: BTreeMap<&'static str, usize>,
pub derived: usize,
pub diagnostics: usize,
pub policy_version: String,
}
impl HealthSummary {
pub fn of(document: &PortableHealth) -> Self {
let mut summary = Self {
policy_version: document.policy_version.clone(),
diagnostics: document.diagnostics.len(),
..Self::default()
};
for state in HealthState::ALL {
summary.by_state.insert(state.as_str(), 0);
}
summary
.by_state
.entry(HealthState::Unknown.as_str())
.or_insert(0);
for entry in &document.scopes {
if !entry.scope.starts_with("issue:") {
continue;
}
summary.issues += 1;
if entry.source == HealthSource::Derived.as_str() {
summary.derived += 1;
}
let key = HealthState::ALL
.into_iter()
.find(|state| state.as_str() == entry.state)
.unwrap_or(HealthState::Unknown)
.as_str();
*summary.by_state.entry(key).or_insert(0) += 1;
}
summary
}
pub fn render(&self) -> String {
let states: Vec<String> = HealthState::ALL
.into_iter()
.map(|state| {
format!(
"{} {}",
self.by_state.get(state.as_str()).copied().unwrap_or(0),
state.as_str()
)
})
.collect();
format!(
"Health ({}): {} issue{} ({}) — {} diagnostic{}\n",
self.policy_version,
self.issues,
if self.issues == 1 { "" } else { "s" },
states.join(", "),
self.diagnostics,
if self.diagnostics == 1 { "" } else { "s" }
)
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn parse_scope_reads_every_scope_kind_and_refuses_the_rest() {
assert_eq!(parse_scope("issue", "42").unwrap(), HealthScope::Issue(42));
assert_eq!(
parse_scope("Project", "p-1").unwrap(),
HealthScope::Project("p-1".to_string())
);
assert_eq!(
parse_scope("cycle", "2026-10").unwrap(),
HealthScope::Cycle("2026-10".to_string())
);
assert_eq!(
parse_scope("", "milestone:m1").unwrap(),
HealthScope::Milestone("m1".to_string())
);
for (scope, id) in [("issue", "not-a-number"), ("team", "x"), ("issue", " ")] {
assert!(parse_scope(scope, id).is_err(), "{scope} {id}");
}
assert!(parse_scope("", "nonsense").is_err());
}
#[test]
fn health_summary_counts_every_state_from_the_document() {
let document = PortableHealth::from_json(
r#"{"format":"tix-health","version":1,"policy_version":"health-v1",
"scopes":[
{"scope":"issue:1","state":"on_track","source":"current"},
{"scope":"issue:2","state":"at_risk","source":"current"},
{"scope":"issue:3","state":"unknown","source":"current"},
{"scope":"issue:4","state":"at_risk","source":"derived",
"rollup":{"state":"at_risk","policy_version":"health-v1",
"source":"derived","contributors":[
{"number":5,"state":"at_risk"}]}},
{"scope":"cycle:2026-10","state":"on_track","source":"derived"}
],
"diagnostics":[]}"#,
)
.expect("reads");
let summary = HealthSummary::of(&document);
assert_eq!(summary.issues, 4, "only issue scopes are counted");
assert_eq!(summary.derived, 1);
assert_eq!(summary.by_state["on_track"], 1);
assert_eq!(summary.by_state["at_risk"], 2);
assert_eq!(summary.by_state["unknown"], 1);
assert_eq!(summary.by_state["needs_attention"], 0);
let line = summary.render();
assert!(line.contains("4 issues"), "{line}");
assert!(line.contains("2 at_risk"), "{line}");
assert!(line.contains("health-v1"), "{line}");
}
#[test]
fn render_derived_names_contributors_and_the_version() {
let derived = crate::health::PortableRollup {
state: "at_risk".to_string(),
policy_version: "health-v1".to_string(),
source: "derived".to_string(),
contributors: vec![
crate::health::PortableContribution {
number: 2,
state: "on_track".to_string(),
recorded: None,
},
crate::health::PortableContribution {
number: 3,
state: "at_risk".to_string(),
recorded: None,
},
],
};
let line = render_derived(&derived);
assert!(
line.contains("at_risk (health-v1, derived, 2 contributors"),
"{line}"
);
assert!(line.contains("#2 on_track, #3 at_risk"), "{line}");
let none = crate::health::PortableRollup {
contributors: Vec::new(),
..derived
};
assert!(render_derived(&none).contains("nothing contributes yet"));
}
}