tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! One pass over the issues, building every membership a health rollup needs
//! (mission `project-issue-health-status-01M3C8HX`, WP03 T013).
//!
//! # One index, no second source of truth
//!
//! A rollup has to answer "what contributes to this scope?" for five kinds of
//! scope, and the memberships already exist: an issue's `parent`, its `cycles`, its
//! `milestones`, and (when their missions land) its projects and epics. This module
//! reads those once and indexes them; it stores nothing, derives nothing about
//! status, and never recomputes what the planning services already answer — the
//! status categories stay [`crate::workflow`]'s and completion stays its own
//! question (C-006).
//!
//! # References that resolve to nothing are diagnostics, not omissions
//!
//! A member naming a cycle the repository does not declare, or a parent that no
//! longer exists, produces a finding ([`HealthDiagnostic`]) **and** still counts as a
//! contributor whose state is the policy's `missing_reference` value — never a
//! silent exclusion, because a scope that quietly dropped an unresolvable child
//! would report a healthier total than it can support (C-005).
//!
//! # Scopes are ids
//!
//! Every scope is a stable id ([`HealthScope`]), and a display name never stands in
//! for one (C-002). A cycle or a milestone is identified by the key its own record
//! carries — which is what the planning services key on today — and the index never
//! resolves a scope by matching a label.

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;

/// The memberships a rollup needs, built in one pass (T013).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HealthIndex {
    /// Issue number -> the state the issue itself carries (C-006's projection).
    states: BTreeMap<u32, (HealthState, Option<String>)>,
    /// Parent number -> its children, in issue order.
    children: BTreeMap<u32, Vec<u32>>,
    /// A scope -> the issues that contribute to it, in issue order.
    members: BTreeMap<HealthScope, Vec<u32>>,
    /// Every diagnostic the pass produced.
    diagnostics: Vec<HealthDiagnostic>,
}

impl HealthIndex {
    /// Index `issues` against the memberships the repository declares.
    ///
    /// `cycles` and `milestones` are the **declared** keys from the planning
    /// services: a member naming one that is not declared is a finding, because the
    /// alternative is a rollup that silently counts a scope nobody configured.
    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 mut index = Self::default();
        for issue in issues {
            let view = project(issue, policy);
            index
                .states
                .insert(issue.number, (view.state, view.recorded.clone()));
            // The state itself is a finding when it cannot be read: the file is fine,
            // the value is not, and a surface has to be able to say which.
            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()),
                );
            }

            // The hierarchy: a parent that exists gets the child; a parent that does
            // not is a finding *and* a contributor (C-005).
            if let Some(parent) = issue.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
                        ),
                    ));
                    // The unresolvable parent is a scope with an unresolvable
                    // contribution: recorded as a member of its own scope so a rollup
                    // asked about it reports the finding and the unknown contribution
                    // rather than an empty, healthy scope.
                    index
                        .members
                        .entry(HealthScope::of_issue(parent))
                        .or_default()
                        .push(issue.number);
                }
            }

            // Cycles and milestones: multiple memberships are supported, and an
            // undeclared key is a finding, not a silent skip.
            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);
                }
            }
        }
        // The member lists are in issue order, and no number appears twice under one
        // scope: two identical memberships are one contribution, not two.
        for members in index.members.values_mut() {
            members.sort_unstable();
            members.dedup();
        }
        index
    }

    /// The state an issue itself carries, and the raw value when it could not be read.
    pub fn state_of(&self, number: u32) -> Option<(HealthState, Option<String>)> {
        self.states.get(&number).cloned()
    }

    /// The children of an issue, in issue order.
    pub fn children_of(&self, number: u32) -> &[u32] {
        self.children.get(&number).map_or(&[], Vec::as_slice)
    }

    /// The issues contributing to a scope, in issue order.
    pub fn members_of(&self, scope: &HealthScope) -> &[u32] {
        self.members.get(scope).map_or(&[], Vec::as_slice)
    }

    /// Every scope **with at least one contributor**, in a stable order.
    ///
    /// A scope nobody contributes to has no entry: a rollup for it still answers
    /// ([`super::rollup`] is total over scopes), and reporting every issue in a large
    /// repository as an empty scope would bury the scopes that have something to say.
    pub fn scopes(&self) -> Vec<HealthScope> {
        self.members.keys().cloned().collect()
    }

    /// Every finding, in the order the pass produced them.
    pub fn diagnostics(&self) -> &[HealthDiagnostic] {
        &self.diagnostics
    }

    /// The diagnostics about one scope.
    pub fn diagnostics_for(&self, scope: &HealthScope) -> Vec<&HealthDiagnostic> {
        self.diagnostics
            .iter()
            .filter(|finding| &finding.scope == scope)
            .collect()
    }

    /// How many issues the pass examined, for a caller that wants to say so.
    pub fn issues_indexed(&self) -> usize {
        self.states.len()
    }
}

#[cfg(test)]
mod tests {
    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"
        );
    }
}