tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Deriving a scope's health from its contributions (mission
//! `project-issue-health-status-01M3C8HX`, WP03 T014/T015).
//!
//! # The formula is the policy's, not this module's
//!
//! "What is a parent's health when one child is at risk and another is on track?"
//! is answered by [`HealthPolicy`] — the worst of the contributions, per a named
//! precedence — and this module only *applies* it. That split is what makes the
//! answer versionable: a rollup reports the version it used, and a stored rollup
//! computed under another version is comparable or not, explicitly.
//!
//! # Nothing is derived from nothing
//!
//! An empty scope resolves to the policy's `empty_scope` (`unknown`), and a scope
//! whose contributors are *all* unknown resolves to `unknown` too — the absence of
//! an answer is never "on track", because the default a reader assumes is healthy
//! and a scope that looks healthy without anybody saying so is the failure this
//! whole mission exists to prevent (C-005, C-006).
//!
//! # Everything is inspectable
//!
//! A rollup carries its contributions (with each contributor's own state), the
//! policy version, the index's diagnostics about the scope, and **one finding per
//! contributor whose state could not be read** — so a surface can say *why* a parent
//! is at risk, and a missing reference or a silent child is visible rather than
//! quietly dropped from the total.

use super::diagnostics::{HealthDiagnostic, HealthDiagnosticKind};
use super::events::{HealthScope, HealthSource};
use super::index::HealthIndex;
use super::policy::HealthPolicy;
use super::state::HealthState;

/// One contributor and the state it carries.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Contribution {
    /// The contributing issue.
    pub number: u32,
    /// Its state, as the projection read it.
    pub state: HealthState,
    /// The raw stored value, when it could not be read.
    pub recorded: Option<String>,
}

/// A derived health value for one scope (T014).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HealthRollup {
    /// The scope it describes.
    pub scope: HealthScope,
    /// The derived state.
    pub state: HealthState,
    /// How the state was obtained.
    pub source: HealthSource,
    /// The policy version it was derived under.
    pub policy_version: String,
    /// Every contributor, in issue order.
    pub contributions: Vec<Contribution>,
    /// Every finding about the scope, from the index.
    pub diagnostics: Vec<HealthDiagnostic>,
}

impl HealthRollup {
    /// Whether the state came from contributions rather than from an empty scope.
    pub fn is_derived(&self) -> bool {
        !self.contributions.is_empty()
    }

    /// The number of contributors.
    pub fn contributing(&self) -> usize {
        self.contributions.len()
    }

    /// The one line a surface shows.
    pub fn render(&self) -> String {
        if self.contributions.is_empty() {
            return format!(
                "{} ({}: nothing contributes to `{}` yet)",
                self.state.label(),
                self.policy_version,
                self.scope
            );
        }
        let tally: Vec<String> = self
            .contributions
            .iter()
            .map(|contribution| format!("#{} {}", contribution.number, contribution.state.as_str()))
            .collect();
        format!(
            "{} ({}, {}, {} contributor{}: {})",
            self.state.label(),
            self.policy_version,
            self.source.as_str(),
            self.contributions.len(),
            if self.contributions.len() == 1 {
                ""
            } else {
                "s"
            },
            tally.join(", ")
        )
    }

    /// The provenance a reader needs to compare two rollups.
    pub fn provenance(&self) -> String {
        format!("{} ({})", self.policy_version, self.source.as_str())
    }
}

/// Derive the health of `scope` (T015).
///
/// Total: a scope the index does not know about, an empty scope, and a scope whose
/// contributors cannot be read all produce a rollup — with `unknown` and the
/// diagnostics that explain it, never a panic and never a healthy default.
pub fn rollup(index: &HealthIndex, scope: &HealthScope, policy: &HealthPolicy) -> HealthRollup {
    let contributions: Vec<Contribution> = index
        .members_of(scope)
        .iter()
        .map(|number| {
            let (state, recorded) = index
                .state_of(*number)
                .unwrap_or((HealthState::Unknown, None));
            // A contributor whose state cannot be read contributes what the policy
            // says a missing contribution is worth, never a healthy default.
            let state = match (state, recorded.is_some()) {
                (HealthState::Unknown, true) => policy.missing_reference(),
                (state, _) => state,
            };
            Contribution {
                number: *number,
                state,
                recorded,
            }
        })
        .collect();

    let state = match policy.worst(contributions.iter().map(|contribution| contribution.state)) {
        Some(state) => state,
        // No *known* contribution: an empty scope resolves to the policy's
        // `empty_scope`, and a scope of unknown contributors is equally unanswered.
        None => policy.empty_scope(),
    };

    // The scope's own diagnostics come first — an unresolvable reference is about the
    // scope, and a reader asking about a parent should see that before the list of
    // children that have nothing to say.
    let mut diagnostics: Vec<HealthDiagnostic> =
        index.diagnostics_for(scope).into_iter().cloned().collect();
    // Then one finding per contributor whose state could not be read: a rollup that
    // reported "on track" while three children had no answer at all would be
    // answering a question nobody asked (C-005, C-006).
    for contribution in &contributions {
        if contribution.state != HealthState::Unknown {
            continue;
        }
        let message = match &contribution.recorded {
            Some(raw) => format!(
                "issue #{} records health `{raw}`, which this build cannot read",
                contribution.number
            ),
            None => format!("issue #{} records no health", contribution.number),
        };
        let mut finding =
            HealthDiagnostic::new(HealthDiagnosticKind::UnknownHealth, scope.clone(), message);
        if let Some(raw) = &contribution.recorded {
            finding = finding.carrying(raw.clone());
        }
        diagnostics.push(finding);
    }

    HealthRollup {
        scope: scope.clone(),
        state,
        source: HealthSource::Derived,
        policy_version: policy.version().to_string(),
        contributions,
        diagnostics,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::Issue;

    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 health_rollup_takes_the_worst_known_contribution() {
        let policy = HealthPolicy::v1();
        let issues = vec![
            issue(1, None, None),
            issue(2, Some(1), Some("on_track")),
            issue(3, Some(1), Some("needs_attention")),
            issue(4, Some(1), Some("unknown")),
        ];
        let index = HealthIndex::build(&issues, &[], &[], &policy);
        let value = rollup(&index, &HealthScope::Issue(1), &policy);
        assert_eq!(value.state, HealthState::NeedsAttention);
        assert_eq!(value.contributing(), 3);
        assert!(value.is_derived());
        assert_eq!(value.source, HealthSource::Derived);
        assert_eq!(value.policy_version, "health-v1");
        let rendered = value.render();
        assert!(rendered.contains("Needs attention"), "{rendered}");
        assert!(rendered.contains("#3 needs_attention"), "{rendered}");
        assert!(rendered.contains("3 contributors"), "{rendered}");

        // Raise one child to at_risk and the parent follows.
        let issues = vec![
            issue(1, None, None),
            issue(2, Some(1), Some("on_track")),
            issue(3, Some(1), Some("at_risk")),
        ];
        let index = HealthIndex::build(&issues, &[], &[], &policy);
        assert_eq!(
            rollup(&index, &HealthScope::Issue(1), &policy).state,
            HealthState::AtRisk
        );
    }

    #[test]
    fn health_rollup_of_nothing_and_of_unknowns_is_unknown() {
        let policy = HealthPolicy::v1();

        // An empty scope.
        let issues = vec![issue(1, None, Some("on_track"))];
        let index = HealthIndex::build(&issues, &[], &[], &policy);
        let empty = rollup(&index, &HealthScope::Issue(9), &policy);
        assert_eq!(empty.state, HealthState::Unknown);
        assert!(!empty.is_derived());
        assert!(
            empty.render().contains("nothing contributes"),
            "{}",
            empty.render()
        );

        // A scope whose contributors all say `unknown`: the answer is that there is
        // no answer, not that the scope is healthy.
        let issues = vec![
            issue(1, None, None),
            issue(2, Some(1), None),
            issue(3, Some(1), Some("unknown")),
        ];
        let index = HealthIndex::build(&issues, &[], &[], &policy);
        let unknowns = rollup(&index, &HealthScope::Issue(1), &policy);
        assert_eq!(unknowns.state, HealthState::Unknown);
        assert_eq!(unknowns.contributing(), 2);
        assert!(
            unknowns.is_derived(),
            "there are contributors, they just say nothing"
        );
    }

    #[test]
    fn health_rollup_reports_an_unreadable_contribution_as_the_policy_says() {
        let policy = HealthPolicy::v1();
        let issues = vec![
            issue(1, None, None),
            issue(2, Some(1), Some("on_track")),
            issue(3, Some(1), Some("fine")),
        ];
        let index = HealthIndex::build(&issues, &[], &[], &policy);
        let value = rollup(&index, &HealthScope::Issue(1), &policy);
        // `health-v1`'s `worst` decides among the *known* contributions, so one
        // readable `on_track` child and one unreadable child answer "on track" —
        // and the unreadable child is not thereby healthy: it is a contributor whose
        // state reads `unknown` (the policy's `missing_reference`), named in the
        // rollup and reported as a finding. C-005 asks for the reference to be
        // visible, not for it to drag the total (an unknown is not a bad state).
        assert_eq!(value.state, HealthState::OnTrack);
        let unreadable = value
            .contributions
            .iter()
            .find(|c| c.recorded.as_deref() == Some("fine"))
            .expect("the unreadable contribution is present");
        assert_eq!(unreadable.state, HealthState::Unknown);
        assert!(value.render().contains("#3 unknown"), "{}", value.render());
        // And the rollup says so about its own scope: a reader asking about the
        // parent is told which of its children have nothing to say.
        assert_eq!(value.diagnostics.len(), 1);
        assert_eq!(value.diagnostics[0].kind.as_str(), "unknown_health");
        assert!(
            value.diagnostics[0].message.contains("`fine`"),
            "{}",
            value.diagnostics[0].message
        );
        assert_eq!(value.diagnostics[0].state.as_deref(), Some("fine"));
        assert!(value.provenance().contains("health-v1"));
    }

    #[test]
    fn health_rollup_names_a_missing_reference_without_hiding_it() {
        let policy = HealthPolicy::v1();
        let issues = vec![
            issue(1, None, None),
            issue(2, Some(1), Some("on_track")),
            issue(3, Some(99), Some("at_risk")),
        ];
        let index = HealthIndex::build(&issues, &[], &[], &policy);
        let missing = rollup(&index, &HealthScope::Issue(99), &policy);
        assert_eq!(
            missing.contributing(),
            1,
            "the unresolvable child still counts"
        );
        assert_eq!(
            missing.state,
            HealthState::AtRisk,
            "and its own state is reported"
        );
        assert_eq!(missing.diagnostics[0].kind.as_str(), "missing_child");
        assert!(
            missing.diagnostics[0].message.contains("#3"),
            "{}",
            missing.diagnostics[0].message
        );
    }
}