use serde::{Deserialize, Serialize};
use toml::Table;
use crate::error::{Result, TixError};
use crate::model::Issue;
use super::diagnostics::{HealthDiagnostic, HealthDiagnosticKind, RemoteTarget};
use super::events::{HealthScope, HealthSource, project};
use super::index::HealthIndex;
use super::policy::HealthPolicy;
use super::rollup::{Contribution, HealthRollup, rollup};
use super::state::HealthState;
pub const FORMAT: &str = "tix-health";
pub const VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableHealth {
pub format: String,
pub version: u32,
pub policy_version: String,
#[serde(default)]
pub scopes: Vec<PortableScopeHealth>,
#[serde(default)]
pub diagnostics: Vec<PortableHealthDiagnostic>,
#[serde(flatten)]
pub extensions: Table,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableScopeHealth {
pub scope: String,
pub state: String,
pub source: String,
#[serde(default)]
pub recorded_policy_version: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub recorded: Option<String>,
#[serde(default)]
pub events: Vec<PortableHealthEvent>,
#[serde(default)]
pub rollup: Option<PortableRollup>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableHealthEvent {
pub kind: String,
pub timestamp: String,
pub actor: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableRollup {
pub state: String,
pub policy_version: String,
pub source: String,
#[serde(default)]
pub contributors: Vec<PortableContribution>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableContribution {
pub number: u32,
pub state: String,
#[serde(default)]
pub recorded: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableHealthDiagnostic {
pub kind: String,
pub scope: String,
#[serde(default)]
pub reference: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub state: Option<String>,
pub message: String,
#[serde(default)]
pub remedy: Option<String>,
}
impl PortableHealthDiagnostic {
pub fn from_diagnostic(diagnostic: &HealthDiagnostic) -> Self {
Self {
kind: diagnostic.kind.as_str().to_string(),
scope: diagnostic.scope.as_str(),
reference: diagnostic.reference.clone(),
field: diagnostic.field.clone(),
target: diagnostic.target.map(|target| target.as_str().to_string()),
state: diagnostic.state.clone(),
message: diagnostic.message.clone(),
remedy: diagnostic.remedy.clone(),
}
}
pub fn to_diagnostic(&self) -> HealthDiagnostic {
let kind = HealthDiagnosticKind::ALL
.into_iter()
.find(|kind| kind.as_str() == self.kind)
.unwrap_or(HealthDiagnosticKind::PolicyMismatch);
let scope = HealthScope::parse(&self.scope).unwrap_or(HealthScope::Issue(0));
let target = RemoteTarget::ALL
.into_iter()
.find(|candidate| Some(candidate.as_str().to_string()) == self.target);
let mut diagnostic = HealthDiagnostic::new(kind, scope, self.message.clone());
diagnostic.reference = self.reference.clone().or_else(|| {
HealthScope::parse(&self.scope)
.is_none()
.then(|| self.scope.clone())
});
diagnostic.field = self.field.clone();
diagnostic.target = target;
diagnostic.state = self.state.clone();
diagnostic.remedy = self.remedy.clone();
diagnostic
}
pub fn same_as(&self, other: &Self) -> bool {
self == other
}
}
impl PortableHealth {
pub fn build(
issues: &[Issue],
cycles: &[String],
milestones: &[String],
policy: &HealthPolicy,
) -> Self {
let index = HealthIndex::build(issues, cycles, milestones, policy);
let mut scopes: Vec<PortableScopeHealth> = Vec::new();
for issue in issues {
let view = project(issue, policy);
let scope = HealthScope::of_issue(issue.number);
let events: Vec<PortableHealthEvent> = issue
.history
.events()
.into_iter()
.filter(|entry| {
matches!(
entry.kind,
crate::model::HistoryEventKind::HealthUpdated
| crate::model::HistoryEventKind::HealthOverridden
)
})
.map(|entry| PortableHealthEvent {
kind: entry.kind.as_str().to_string(),
timestamp: entry.timestamp.clone(),
actor: entry.actor.clone(),
detail: entry.detail.clone(),
})
.collect();
let recorded = view.recorded.clone();
scopes.push(PortableScopeHealth {
scope: scope.as_str(),
state: view.state.as_str().to_string(),
source: view.source.as_str().to_string(),
recorded_policy_version: view.policy_version.clone(),
updated_at: view.updated_at.clone(),
recorded,
events,
rollup: None,
});
}
let known: std::collections::BTreeSet<u32> =
issues.iter().map(|issue| issue.number).collect();
for scope in index.scopes() {
if let HealthScope::Issue(number) = scope {
if !known.contains(&number) {
continue;
}
}
let derived = rollup(&index, &scope, policy);
let entry = PortableScopeHealth::from_rollup(&derived);
match scopes
.iter_mut()
.find(|entry| entry.scope == scope.as_str())
{
Some(existing) => existing.rollup = entry.rollup,
None => scopes.push(entry),
}
}
scopes.sort_by(|left, right| left.scope.cmp(&right.scope));
let mut document = Self {
format: FORMAT.to_string(),
version: VERSION,
policy_version: policy.version().to_string(),
scopes,
diagnostics: index
.diagnostics()
.iter()
.map(PortableHealthDiagnostic::from_diagnostic)
.collect(),
extensions: Table::new(),
};
document.refresh_diagnostics();
document
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self)
.map_err(|error| TixError::Parse(format!("portable health document: {error}")))
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|error| TixError::Parse(format!("portable health document: {error}")))
}
pub fn from_json(text: &str) -> Result<Self> {
let mut document: Self = serde_json::from_str(text)
.map_err(|error| TixError::Parse(format!("portable health document: {error}")))?;
document.refresh_diagnostics();
Ok(document)
}
pub fn derived_diagnostics(&self) -> Vec<HealthDiagnostic> {
let mut derived: Vec<HealthDiagnostic> = Vec::new();
for scope in &self.scopes {
let Some(rollup) = &scope.rollup else {
continue;
};
for contributor in &rollup.contributors {
if contributor.state != HealthState::Unknown.as_str() {
continue;
}
let scope_id = HealthScope::parse(&scope.scope).unwrap_or(HealthScope::Issue(0));
let message = match &contributor.recorded {
Some(raw) => format!(
"issue #{} records health `{raw}`, which this build cannot read",
contributor.number
),
None => format!("issue #{} records no health", contributor.number),
};
let mut diagnostic =
HealthDiagnostic::new(HealthDiagnosticKind::UnknownHealth, scope_id, message);
diagnostic.state = contributor.recorded.clone();
derived.push(diagnostic);
}
}
derived
}
pub fn refresh_diagnostics(&mut self) {
let derived = self.derived_diagnostics();
let derived_wire: Vec<PortableHealthDiagnostic> = derived
.iter()
.map(PortableHealthDiagnostic::from_diagnostic)
.collect();
let mut merged: Vec<PortableHealthDiagnostic> =
Vec::with_capacity(self.diagnostics.len() + derived_wire.len());
for diagnostic in self.diagnostics.iter().chain(derived_wire.iter()) {
if !merged.iter().any(|seen| seen.same_as(diagnostic)) {
merged.push(diagnostic.clone());
}
}
self.diagnostics = merged;
}
pub fn scope(&self, scope: &HealthScope) -> Option<&PortableScopeHealth> {
let id = scope.as_str();
self.scopes.iter().find(|entry| entry.scope == id)
}
pub fn diagnostics_for(&self, scope: &HealthScope) -> Vec<&PortableHealthDiagnostic> {
let id = scope.as_str();
self.diagnostics
.iter()
.filter(|diagnostic| diagnostic.scope == id)
.collect()
}
pub fn carries_unknown_data(&self) -> bool {
let known = HealthDiagnosticKind::ALL.map(HealthDiagnosticKind::as_str);
self.diagnostics
.iter()
.any(|diagnostic| !known.contains(&diagnostic.kind.as_str()))
}
}
impl PortableScopeHealth {
pub fn from_rollup(derived: &HealthRollup) -> Self {
Self {
scope: derived.scope.as_str(),
state: derived.state.as_str().to_string(),
source: HealthSource::Derived.as_str().to_string(),
recorded_policy_version: None,
updated_at: None,
recorded: None,
events: Vec::new(),
rollup: Some(PortableRollup {
state: derived.state.as_str().to_string(),
policy_version: derived.policy_version.clone(),
source: derived.source.as_str().to_string(),
contributors: derived.contributions.iter().map(contribution).collect(),
}),
}
}
}
fn contribution(value: &Contribution) -> PortableContribution {
PortableContribution {
number: value.number,
state: value.state.as_str().to_string(),
recorded: value.recorded.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn issue(number: u32, parent: Option<u32>, health: Option<&str>) -> Issue {
let parent = parent.map_or(String::new(), |p| format!("parent: {p}\n"));
let health = health
.map(|state| format!("health:\n state: {state}\n"))
.unwrap_or_default();
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{parent}{health}---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = format!("issue-{number}.md");
issue
}
#[test]
fn portable_health_round_trips_and_is_idempotent() {
let policy = HealthPolicy::v1();
let issues = vec![
issue(1, None, Some("on_track")),
issue(2, Some(1), Some("at_risk")),
issue(3, Some(1), None),
];
let document = PortableHealth::build(&issues, &[], &[], &policy);
assert_eq!(document.format, FORMAT);
assert_eq!(document.version, VERSION);
assert_eq!(document.policy_version, "health-v1");
let first = document.to_json().expect("serializes");
let read = PortableHealth::from_json(&first).expect("reads back");
assert_eq!(
read, document,
"the document is a fixed point of the round trip"
);
let second = read.to_json().expect("serializes again");
assert_eq!(first, second, "JSON is byte-stable across round trips");
assert_eq!(read.diagnostics.len(), document.diagnostics.len());
let parent = document.scope(&HealthScope::Issue(1)).expect("the parent");
let rollup = parent.rollup.as_ref().expect("a derived rollup");
assert_eq!(rollup.state, "at_risk");
assert_eq!(rollup.contributors.len(), 2);
assert_eq!(rollup.contributors[0].number, 2);
assert!(
document
.diagnostics
.iter()
.any(|d| d.kind == "unknown_health" && d.scope == "issue:1")
);
}
#[test]
fn portable_health_preserves_what_this_build_cannot_read() {
let policy = HealthPolicy::v1();
let issues = vec![
issue(1, None, Some("on_track")),
issue(2, Some(1), Some("from_the_future")),
];
let document = PortableHealth::build(&issues, &[], &[], &policy);
let child = document.scope(&HealthScope::Issue(2)).expect("the child");
assert_eq!(child.recorded.as_deref(), Some("from_the_future"));
assert_eq!(child.state, "unknown");
assert!(
document
.diagnostics
.iter()
.any(|d| d.kind == "invalid_state")
);
let json = document.to_json().expect("serializes");
let widened = json.replace(
"\"policy_version\"",
"\"future_section\":{\"nested\":7},\"policy_version\"",
);
let read = PortableHealth::from_json(&widened).expect("reads a newer document");
assert!(
read.extensions.contains_key("future_section"),
"an unknown key is kept, not dropped"
);
assert!(
read.to_json()
.expect("serializes")
.contains("future_section")
);
}
#[test]
fn portable_health_reads_an_unknown_diagnostic_kind_without_losing_it() {
let document = PortableHealth::from_json(
r#"{"format":"tix-health","version":1,"policy_version":"health-v9",
"scopes":[],
"diagnostics":[{"kind":"invented_by_a_newer_build","scope":"issue:4",
"message":"something this build does not know"}]}"#,
)
.expect("reads");
assert!(document.carries_unknown_data());
let diagnostic = document.diagnostics[0].to_diagnostic();
assert_eq!(diagnostic.message, "something this build does not know");
assert_eq!(diagnostic.scope, HealthScope::Issue(4));
assert_eq!(
diagnostic.kind,
HealthDiagnosticKind::PolicyMismatch,
"an unrecognized kind is reported, not dropped"
);
}
}