use std::collections::{BTreeMap, BTreeSet};
use crate::model::Issue;
use super::diagnostics::{HealthDiagnostic, HealthDiagnosticKind};
use super::events::{HealthScope, project};
use super::policy::HealthPolicy;
use super::state::HealthState;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HealthIndex {
states: BTreeMap<u32, (HealthState, Option<String>)>,
children: BTreeMap<u32, Vec<u32>>,
members: BTreeMap<HealthScope, Vec<u32>>,
diagnostics: Vec<HealthDiagnostic>,
}
impl HealthIndex {
pub fn build(
issues: &[Issue],
cycles: &[String],
milestones: &[String],
policy: &HealthPolicy,
) -> Self {
let known_cycles: BTreeSet<&str> = cycles.iter().map(String::as_str).collect();
let known_milestones: BTreeSet<&str> = milestones.iter().map(String::as_str).collect();
let numbers: BTreeSet<u32> = issues.iter().map(|issue| issue.number).collect();
let aliases = crate::id::AliasIndex::build(issues);
let mut index = Self::default();
for issue in issues {
let view = project(issue, policy);
index
.states
.insert(issue.number, (view.state, view.recorded.clone()));
if let Some(diagnostic) = &view.diagnostic {
index.diagnostics.push(
HealthDiagnostic::new(
HealthDiagnosticKind::InvalidState,
HealthScope::of_issue(issue.number),
diagnostic.render(),
)
.carrying(view.recorded.clone().unwrap_or_default()),
);
}
if let Some(parent_ref) = issue.parent.as_ref() {
let parent = aliases
.resolve(parent_ref)
.ok()
.and_then(|resolved| resolved.as_number())
.or_else(|| parent_ref.as_number());
if let Some(parent) = parent {
if numbers.contains(&parent) {
index.children.entry(parent).or_default().push(issue.number);
index
.members
.entry(HealthScope::of_issue(parent))
.or_default()
.push(issue.number);
} else {
index.diagnostics.push(HealthDiagnostic::new(
HealthDiagnosticKind::MissingChild,
HealthScope::of_issue(parent),
format!(
"issue #{} names parent #{parent}, which does not exist",
issue.number
),
));
index
.members
.entry(HealthScope::of_issue(parent))
.or_default()
.push(issue.number);
}
} else {
let display = parent_ref.to_string();
index.diagnostics.push(HealthDiagnostic::new(
HealthDiagnosticKind::MissingChild,
HealthScope::of_issue(issue.number),
format!(
"issue #{} names parent {display}, which does not exist",
issue.number
),
));
index
.members
.entry(HealthScope::of_issue(issue.number))
.or_default()
.push(issue.number);
}
}
for (scope_of, keys, known, kind) in [
(
HealthScope::Cycle as fn(String) -> HealthScope,
issue.cycles.clone().unwrap_or_default(),
&known_cycles,
HealthDiagnosticKind::MissingCycle,
),
(
HealthScope::Milestone as fn(String) -> HealthScope,
issue.milestones.clone().unwrap_or_default(),
&known_milestones,
HealthDiagnosticKind::MissingMilestone,
),
] {
for key in keys {
if !known.contains(key.as_str()) {
index.diagnostics.push(HealthDiagnostic::new(
kind,
scope_of(key.clone()),
format!(
"issue #{} names `{key}`, which this repository does not \
declare",
issue.number
),
));
}
index
.members
.entry(scope_of(key))
.or_default()
.push(issue.number);
}
}
}
for members in index.members.values_mut() {
members.sort_unstable();
members.dedup();
}
index
}
pub fn state_of(&self, number: u32) -> Option<(HealthState, Option<String>)> {
self.states.get(&number).cloned()
}
pub fn children_of(&self, number: u32) -> &[u32] {
self.children.get(&number).map_or(&[], Vec::as_slice)
}
pub fn members_of(&self, scope: &HealthScope) -> &[u32] {
self.members.get(scope).map_or(&[], Vec::as_slice)
}
pub fn scopes(&self) -> Vec<HealthScope> {
self.members.keys().cloned().collect()
}
pub fn diagnostics(&self) -> &[HealthDiagnostic] {
&self.diagnostics
}
pub fn diagnostics_for(&self, scope: &HealthScope) -> Vec<&HealthDiagnostic> {
self.diagnostics
.iter()
.filter(|finding| &finding.scope == scope)
.collect()
}
pub fn issues_indexed(&self) -> usize {
self.states.len()
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn issue(
number: u32,
parent: Option<u32>,
cycles: &[&str],
milestones: &[&str],
extra: &str,
) -> Issue {
let cycles = if cycles.is_empty() {
String::new()
} else {
let list = cycles
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
format!("cycles: [{list}]\n")
};
let milestones = if milestones.is_empty() {
String::new()
} else {
let list = milestones
.iter()
.map(|m| format!("\"{m}\""))
.collect::<Vec<_>>()
.join(", ");
format!("milestones: [{list}]\n")
};
let parent = parent.map_or(String::new(), |p| format!("parent: {p}\n"));
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{parent}{cycles}{milestones}{extra}---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = format!("issue-{number}.md");
issue
}
#[test]
fn health_index_collects_children_memberships_and_diagnostics() {
let policy = HealthPolicy::v1();
let issues = vec![
issue(1, None, &["2026-01"], &["m1"], ""),
issue(2, Some(1), &["2026-01"], &[], ""),
issue(3, Some(1), &["nope"], &[], ""),
issue(4, Some(99), &[], &[], ""),
];
let index = HealthIndex::build(
&issues,
&["2026-01".to_string()],
&["m1".to_string()],
&policy,
);
assert_eq!(index.issues_indexed(), 4);
assert_eq!(index.children_of(1), &[2, 3]);
assert_eq!(
index.members_of(&HealthScope::Issue(1)),
&[2, 3],
"a parent's contributors are its children"
);
assert_eq!(
index.members_of(&HealthScope::Cycle("2026-01".to_string())),
&[1, 2]
);
assert_eq!(
index.members_of(&HealthScope::Milestone("m1".to_string())),
&[1]
);
let kinds: Vec<&str> = index
.diagnostics()
.iter()
.map(|finding| finding.kind.as_str())
.collect();
assert!(kinds.contains(&"missing_cycle"), "{kinds:?}");
assert!(kinds.contains(&"missing_child"), "{kinds:?}");
assert_eq!(
index.diagnostics_for(&HealthScope::Issue(99)).len(),
1,
"the unresolvable parent is a scope with a finding"
);
assert_eq!(
index.members_of(&HealthScope::Issue(99)),
&[4],
"and that child still contributes, so the scope cannot read as empty"
);
}
#[test]
fn health_index_reads_states_and_reports_ones_it_cannot() {
let policy = HealthPolicy::v1();
let issues = vec![
issue(1, None, &[], &[], "health:\n state: on_track\n"),
issue(2, None, &[], &[], "health:\n state: fine\n"),
issue(3, None, &[], &[], ""),
];
let index = HealthIndex::build(&issues, &[], &[], &policy);
assert_eq!(
index.state_of(1).map(|(state, _)| state),
Some(HealthState::OnTrack)
);
assert_eq!(
index.state_of(2).map(|(state, _)| state),
Some(HealthState::Unknown)
);
assert_eq!(
index.state_of(2).and_then(|(_, raw)| raw),
Some("fine".to_string())
);
assert_eq!(
index.state_of(3).map(|(state, _)| state),
Some(HealthState::Unknown)
);
let invalid: Vec<&HealthDiagnostic> = index
.diagnostics()
.iter()
.filter(|finding| finding.kind == HealthDiagnosticKind::InvalidState)
.collect();
assert_eq!(invalid.len(), 1, "only the unreadable value is a finding");
assert!(
invalid[0].message.contains("fine"),
"{}",
invalid[0].message
);
assert!(invalid[0].render().contains("invalid_state"));
}
#[test]
fn health_index_keeps_duplicate_memberships_as_one_contribution() {
let policy = HealthPolicy::v1();
let issues = vec![issue(1, None, &["2026-01", "2026-01"], &[], "")];
let index = HealthIndex::build(&issues, &["2026-01".to_string()], &[], &policy);
assert_eq!(
index.members_of(&HealthScope::Cycle("2026-01".to_string())),
&[1],
"a membership named twice is one contribution"
);
}
}